"""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")