Files
PacketStation/server/packetstation/pdf/stamps.py
T

281 lines
11 KiB
Python
Raw Normal View History

2026-10-17 23:49:14 -06:00
"""Stamps and metadata: the controlled-copy line, the PRINTED COPY box, the stamp signature, the VOID overlay on a
copied drawing page, and the document information.
The stamp signature is 16 hex characters of HMAC-SHA256 with the seal key over
"stamp|{packet_id}|{kind}|{seal hash}|{user id}|{minute}". The minute is the one the stamp line names, as
YYYY-MM-DDTHH:MMZ (style.stamp_at), so a printed copy can be checked from its own stamp and the print log.
"""
from __future__ import annotations
import hashlib
import hmac
import io
import math
from collections.abc import Callable, Iterable, Mapping
from datetime import datetime
from typing import Any
from pypdf import PdfReader, PdfWriter, Transformation
from reportlab.lib.colors import Color
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import Canvas
from .style import GREY, WhoRef, as_dt, ascii_only, pdf_date, stamp_at, utc_stamp, who_text
AUTHOR = "PESCO - Process Equipment & Service Company, Inc."
PRODUCER = "Packet Station"
SIG_LEN = 16
Draw = Callable[[Canvas, int, float, float], None]
# ---------------------------------------------------------------- texts
def controlled_stamp(who: WhoRef | None, now: datetime | None, *, mode: str, seal_hash_hex: str | None = None) -> str:
"""The controlled-copy line. mode "filed" names the seal (first 12 hex), "preview" says not yet filed, "print"
says printed by (and the seal or not yet filed), "traveler" carries no seal part."""
verb = "printed" if mode == "print" else "exported"
text = f"Controlled copy - {verb} by {who_text(who)} {utc_stamp(now)}"
if mode != "traveler":
text += f" - seal {seal_hash_hex[:12]}" if seal_hash_hex else " - not yet filed"
return ascii_only(text)
def project_lot(packet: Mapping[str, Any] | None) -> tuple[str, str]:
"""The project number and the lot of a packet read model, as text."""
unit = (packet or {}).get("unit") or {}
project = unit.get("project") or {}
return str(project.get("number") or ""), str(unit.get("lot") or "")
def print_stamp(who: WhoRef | None, now: datetime | None, what: str, packet: Mapping[str, Any] | None) -> str:
"""PRINTED COPY - {what} - printed by Name (emp) {UTC} - {project} Lot {unit} dwg {drawing} R{rev} - verify
revision before use. Anything outside printable ASCII becomes "-"."""
text = f"PRINTED COPY - {what} - printed by {who_text(who)} {utc_stamp(now)}"
if packet is not None:
project, lot = project_lot(packet)
text += f" - {project} Lot {lot} dwg {packet.get('drawing') or ''} R{packet.get('rev') or ''}"
text += " - verify revision before use"
return ascii_only(text, "-")
def minute_of(at_iso: Any) -> str:
"""The signed minute: any ISO time (or datetime) as YYYY-MM-DDTHH:MMZ in UTC. Text that does not parse is
signed as it is."""
v = as_dt(at_iso)
if isinstance(v, datetime):
return stamp_at(v)
return str(at_iso or "")
def stamp_sig(key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any) -> str:
"""16 hex characters of HMAC-SHA256 over "stamp|" + the fields joined with "|". The time is signed to the
minute the stamp line shows (minute_of), so a stamp can be checked from its own text."""
parts = [
str(packet_id if packet_id is not None else ""),
str(kind),
seal_hash_hex or "",
str(user_id or ""),
minute_of(at_iso),
]
msg = ("stamp|" + "|".join(parts)).encode("utf-8")
return hmac.new(key, msg, hashlib.sha256).hexdigest()[:SIG_LEN]
def verify_stamp(
key: bytes, packet_id: Any, kind: str, seal_hash_hex: str | None, user_id: Any, at_iso: Any, sig: str
) -> bool:
"""True when `sig` is the stamp signature of these fields (the stamp_sig arguments, then the signature)."""
want = stamp_sig(key, packet_id, kind, seal_hash_hex, user_id, at_iso)
return hmac.compare_digest(want, str(sig or "").strip().lower())
def with_sig(text: str, sig: str) -> str:
return f"{text} - stamp {sig}" if sig else text
def signed_stamp(
settings: Any,
text: str,
*,
packet_id: Any,
kind: str,
seal_hash_hex: str | None,
who: WhoRef | None,
now: datetime | None,
warnings: list[str] | None = None,
) -> tuple[str, str]:
"""The stamp line with its signature, and the signature. Without a readable seal key the line goes out unsigned
and a warning is added."""
try:
key = settings.seal_key()
except Exception: # the key file is missing or unreadable: stamp without a signature
if warnings is not None:
warnings.append("The seal key is not available, so the stamp is not signed.")
return text, ""
sig = stamp_sig(key, packet_id, kind, seal_hash_hex, who.get("id") if who else None, stamp_at(now))
return with_sig(text, sig), sig
# ---------------------------------------------------------------- drawing on pages
def draw_line_stamp(c: Canvas, w: float, text: str, *, own: bool) -> None:
"""The controlled-copy line: on our pages at x=48, y=9 in 6.5 pt grey; on copied pages centred at y=3 in 6 pt."""
c.setFillColor(Color(GREY[0] / 255, GREY[1] / 255, GREY[2] / 255))
if own:
c.setFont("Helvetica", 6.5)
c.drawString(48, 9, text)
else:
c.setFont("Helvetica", 6)
c.drawString(max(2.0, (w - stringWidth(text, "Helvetica", 6)) / 2), 3, text)
def draw_box_stamp(c: Canvas, w: float, text: str) -> None:
"""The old print stamp: a white box at 92 % opacity at the bottom centre, the text in #333."""
fs = min(12.0, max(7.0, w / 150))
tw = stringWidth(text, "Helvetica", fs)
room = w - fs * 1.2 - 4
if tw > room:
fs = max(3.5, fs * room / tw)
tw = stringWidth(text, "Helvetica", fs)
pad = fs * 0.5
bh = fs * 1.6
x = max(pad, (w - tw) / 2)
y0 = pad * 0.4
c.saveState()
c.setFillColor(Color(1, 1, 1))
c.setFillAlpha(0.92)
c.rect(x - pad, y0, tw + pad * 2, bh, stroke=0, fill=1)
c.restoreState()
c.setFont("Helvetica", fs)
c.setFillColor(Color(0x33 / 255, 0x33 / 255, 0x33 / 255))
c.drawString(x, y0 + bh / 2 - fs * 0.35, text)
def draw_void(c: Canvas, w: float, h: float, sub: str) -> None:
"""VOID on a copied drawing page: HelveticaBold w/5.5 along the diagonal at 30 % red, the void text at 12 %
height in w/70, a red border inset 4 pt, width max(3, w/250), at 70 %."""
size = w / 5.5
tw = stringWidth("VOID", "Helvetica-Bold", size)
ang = math.atan2(h, w) * 0.85
x = w / 2 - math.cos(ang) * tw / 2 + math.sin(ang) * size * 0.35
y = h / 2 - math.sin(ang) * tw / 2 - math.cos(ang) * size * 0.35
red = Color(0.8, 0, 0)
c.saveState()
c.setFillColor(red)
c.setFillAlpha(0.3)
c.translate(x, y)
c.rotate(math.degrees(ang))
c.setFont("Helvetica-Bold", size)
c.drawString(0, 0, "VOID")
c.restoreState()
text = ascii_only(sub, "-")
ss = w / 70
c.setFillColor(red)
c.setFont("Helvetica-Bold", ss)
c.drawString(w / 2 - stringWidth(text, "Helvetica-Bold", ss) / 2, h * 0.12, text)
c.saveState()
c.setStrokeColor(red)
c.setStrokeAlpha(0.7)
c.setLineWidth(max(3.0, w / 250))
c.rect(4, 4, w - 8, h - 8, stroke=1, fill=0)
c.restoreState()
# ---------------------------------------------------------------- overlay
def page_size(page: Any) -> tuple[float, float, float, float]:
"""(llx, lly, width, height) of a pypdf page's MediaBox."""
box = page.mediabox
return float(box.left), float(box.bottom), float(box.width), float(box.height)
def overlay_writer(writer: PdfWriter, draw: Draw, pages: Iterable[int] | None = None) -> None:
"""Draw on every page (or the given page indices) of a writer: one reportlab page per page, merged on top.
A page with /Rotate has its rotation moved into its content first, so the overlay reads upright."""
wanted = list(range(len(writer.pages))) if pages is None else sorted(set(pages))
if not wanted:
return
buf = io.BytesIO()
c = Canvas(buf, pageCompression=1)
boxes: list[tuple[float, float]] = []
for i in wanted:
page = writer.pages[i]
if int(page.get("/Rotate", 0) or 0) % 360:
page.transfer_rotation_to_content()
llx, lly, w, h = page_size(page)
boxes.append((llx, lly))
c.setPageSize((w, h))
draw(c, i, w, h)
c.showPage()
c.save()
ov = PdfReader(io.BytesIO(buf.getvalue()))
for n, i in enumerate(wanted):
llx, lly = boxes[n]
if llx or lly:
writer.pages[i].merge_transformed_page(ov.pages[n], Transformation().translate(llx, lly))
else:
writer.pages[i].merge_page(ov.pages[n])
def overlay(pdf_bytes: bytes, draw: Draw, *, metadata: Mapping[str, str] | None = None) -> bytes:
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
overlay_writer(writer, draw)
if metadata:
writer.add_metadata(dict(metadata))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def apply_stamp(pdf_bytes: bytes, text: str, *, ours: set[int] | None = None, box: bool = False) -> bytes:
"""Draw the stamp line on every page. `ours` are the 0-based indices of our generated pages (None: all are
ours). box=True draws the PRINTED COPY box instead."""
def draw(c: Canvas, i: int, w: float, h: float) -> None:
del h
if box:
draw_box_stamp(c, w, text)
else:
draw_line_stamp(c, w, text, own=ours is None or i in ours)
return overlay(pdf_bytes, draw)
def metadata(title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None) -> dict[str, str]:
stamp = pdf_date(now)
return {
"/Title": ascii_only(title, "-"),
"/Author": AUTHOR,
"/Subject": ascii_only(subject, "-"),
"/Keywords": ascii_only(keywords, "-"),
"/Creator": ascii_only(creator, "-"),
"/Producer": PRODUCER,
"/CreationDate": stamp,
"/ModDate": stamp,
}
def set_metadata(
pdf_bytes: bytes, title: str, subject: str, keywords: str, creator: str, *, now: datetime | None = None
) -> bytes:
"""The document information: title, subject, keywords, creator, Author PESCO, Producer Packet Station and the
creation date."""
writer = PdfWriter(clone_from=PdfReader(io.BytesIO(pdf_bytes)))
writer.add_metadata(metadata(title, subject, keywords, creator, now=now))
out = io.BytesIO()
writer.write(out)
return out.getvalue()
def write(writer: PdfWriter, meta: Mapping[str, str] | None = None) -> bytes:
if meta:
writer.add_metadata(dict(meta))
out = io.BytesIO()
writer.write(out)
return out.getvalue()