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
|
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
|
import math
|
2026-10-17 23:49:14 -06:00
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 15:08:34 -06:00
|
|
|
|
def _hms(ms: Any) -> tuple[int, int, int]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
t = max(0, int(ms or 0) // 1000)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
t = 0
|
|
|
|
|
|
return t // 3600, t % 3600 // 60, t % 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fmt_clock_hms(ms: Any) -> str:
|
|
|
|
|
|
"""Elapsed milliseconds as the old fmtClock(): HH:MM:SS, zero padded (the weld record's time recorded)."""
|
|
|
|
|
|
h, m, s = _hms(ms)
|
|
|
|
|
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fmt_clock_hhmm(ms: Any) -> str:
|
|
|
|
|
|
"""Elapsed milliseconds as HH:MM, zero padded (the cover's weld time, the old fmtClock().slice(0,5))."""
|
|
|
|
|
|
h, m, _s = _hms(ms)
|
|
|
|
|
|
return f"{h:02d}:{m:02d}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def round_half_up(x: float) -> int:
|
|
|
|
|
|
"""JavaScript's Math.round for positive numbers: 8.5 gives 9 (Python's round gives 8)."""
|
|
|
|
|
|
return math.floor(x + 0.5)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-17 23:49:14 -06:00
|
|
|
|
# ---------------------------------------------------------------- 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")
|