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

228 lines
6.7 KiB
Python
Raw Normal View History

2026-10-17 23:49:14 -06:00
"""Shared look and helpers for the PDF builders: colours, page geometry, text cleaning, dates and the result types.
The colours, the page frame and the address lines are the old databook.js values (DBC, L, R, CW, PESCO_ADDR).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any, TypedDict
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
# ---------------------------------------------------------------- colours (RGB 0..255)
NAVY = (31, 43, 69)
TEAL = (13, 127, 146)
GREY = (107, 114, 128)
LINE = (213, 217, 223)
PANEL = (244, 246, 249)
AMBER = (138, 97, 0)
AMBER_BG = (253, 246, 230)
RED = (154, 43, 43)
GREEN = (30, 110, 70)
WHITE = (255, 255, 255)
GREEN_BG = (232, 245, 238)
RED_BG = (250, 234, 234)
REMARK = (55, 65, 81)
# ---------------------------------------------------------------- page geometry (points, letter portrait)
PAGE_W = 612.0
PAGE_H = 792.0
L = 48.0
R = 564.0
CW = R - L
PESCO_ADDR = ("5680 Highway 64", "Farmington, NM 87401", "505.327.2222 • pescoinc.biz")
PESCO_NAME = "PESCO - Process Equipment & Service Company, Inc."
# ---------------------------------------------------------------- results
class PdfError(Exception):
"""A PDF could not be assembled. Per-part problems never raise this; they become warnings."""
class NothingToPrint(PdfError):
"""The print kind has nothing to print for this packet, or the sheet or document is not one of its own."""
class WhoRef(TypedDict):
"""Who builds the PDF: the user id, the display name and the employee number (text, may be None)."""
id: str | None
display_name: str | None
emp_no: str | None
class IndexRow(TypedDict):
"""One row of the data book index. section is 1..7; page is the first page number or None."""
section: int
doc: str
ref: str | None
rev: str | None
page: int | None
warn: bool
count: int
@dataclass
class Built:
"""A built PDF. `stamp` is the stamp line as printed, `stamp_sig` the 16 hex signed into it, `what` the label."""
bytes: bytes
pages: int
index: list[IndexRow] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stamp: str = ""
stamp_sig: str = ""
what: str = ""
# ---------------------------------------------------------------- text
_MAP = str.maketrans(
{
"‘": "'",
"’": "'",
"“": '"',
"”": '"',
"–": "-",
"—": "-",
"→": "->",
"\t": " ",
}
)
_BULLET = "•"
_DROP = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
def clean(text: Any) -> str:
"""The old clean(): curly quotes to straight, dashes to "-", arrows to "->". Anything outside Latin-1 is
dropped except the bullet. Control characters are dropped too; a newline stays (it breaks a table cell)."""
s = "" if text is None else str(text)
s = s.replace("\r\n", "\n").replace("\r", "\n").translate(_MAP)
s = "".join(ch for ch in s if ord(ch) <= 0xFF or ch == _BULLET)
return _DROP.sub("", s)
def ascii_only(text: Any, repl: str = "") -> str:
"""Printable ASCII only: everything else becomes `repl`."""
return re.sub(r"[^\x20-\x7e]", repl, clean(text).replace("\n", " "))
def who_text(who: WhoRef | None) -> str:
"""'Name (emp)'. A missing employee number reads 'Name (?)'."""
if not who:
return "unknown"
name = str(who.get("display_name") or "").strip() or "unknown"
emp = str(who.get("emp_no") or "").strip() or "?"
return f"{name} ({emp})"
def fmt_num(value: Any) -> str:
"""A quantity or hours without trailing zeros: 2, 2.5. Text stays as it is."""
if value is None or value == "":
return ""
if isinstance(value, bool):
return str(value)
if isinstance(value, int):
return str(value)
if isinstance(value, Decimal | float):
d = Decimal(str(value)).normalize()
out = format(d, "f")
return out.rstrip("0").rstrip(".") if "." in out else out
return str(value)
def fmt_clock(ms: Any) -> str:
"""Elapsed milliseconds as h:mm."""
try:
t = max(0, int(ms or 0) // 1000)
except (TypeError, ValueError):
t = 0
return f"{t // 3600}:{t % 3600 // 60:02d}"
# ---------------------------------------------------------------- dates
def zone(tz: str | None) -> ZoneInfo:
try:
return ZoneInfo(tz or "America/Denver")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("America/Denver")
def as_dt(value: Any) -> datetime | date | None:
"""A datetime or date from a column value or an ISO string (jsonb snapshots store ISO text)."""
if value is None or value == "":
return None
if isinstance(value, datetime | date):
return value
if isinstance(value, int | float):
try:
return datetime.fromtimestamp(float(value) / 1000, UTC)
except (OverflowError, OSError, ValueError):
return None
try:
return datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except ValueError:
return None
def _local(value: Any, tz: str | None) -> datetime | date | None:
v = as_dt(value)
if isinstance(v, datetime) and v.tzinfo is not None:
return v.astimezone(zone(tz))
return v
def fmt_d(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY in the site time zone."""
v = _local(value, tz)
if v is None:
return empty
return f"{v.month}/{v.day}/{v.year}"
def fmt_dt(value: Any, tz: str | None = None, empty: str = "—") -> str:
"""M/D/YYYY h:mm AM in the site time zone. A plain date gives the date only."""
v = _local(value, tz)
if v is None:
return empty
if not isinstance(v, datetime):
return f"{v.month}/{v.day}/{v.year}"
h = v.hour % 12 or 12
return f"{v.month}/{v.day}/{v.year} {h}:{v.minute:02d} {'AM' if v.hour < 12 else 'PM'}"
def utc_now(now: datetime | None = None) -> datetime:
n = now or datetime.now(UTC)
if n.tzinfo is None:
n = n.replace(tzinfo=UTC)
return n.astimezone(UTC)
def utc_stamp(now: datetime | None = None) -> str:
"""YYYY-MM-DD HH:MM UTC."""
return utc_now(now).strftime("%Y-%m-%d %H:%M UTC")
def stamp_at(now: datetime | None = None) -> str:
"""The minute a stamp names, as signed into the stamp signature: YYYY-MM-DDTHH:MMZ."""
return utc_now(now).strftime("%Y-%m-%dT%H:%MZ")
def pdf_date(now: datetime | None = None) -> str:
"""A PDF date string: D:YYYYMMDDHHmmSSZ."""
return utc_now(now).strftime("D:%Y%m%d%H%M%SZ")
def site_tz(settings: Any) -> str:
return str(getattr(settings, "site_tz", None) or "America/Denver")