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"
|
2026-10-18 15:08:34 -06:00
|
|
|
says printed by (and the seal or not yet filed), "traveler" carries no seal part, "report" (the PM production
|
|
|
|
|
status report) names Packet Station and carries no seal part."""
|
|
|
|
|
if mode == "report":
|
|
|
|
|
return ascii_only(f"Controlled copy - Packet Station - printed by {who_text(who)} {utc_stamp(now)}")
|
2026-10-17 23:49:14 -06:00
|
|
|
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()
|