The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target, Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as "report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates, search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and iPad viewports. Docs updated; PLAN.md questions 38 to 42. Co-Authored-By: Claude Fable 5.1 <[email protected]>
413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""The letter-page toolkit over a reportlab canvas: a port of the old databook.js text helpers and its autotable look.
|
|
|
|
Every y here is measured from the top of the page, as jsPDF did, and turned into reportlab's bottom-up y on drawing.
|
|
Text is always passed through `clean`. Cells are wrapped with reportlab's simpleSplit on plain strings, so no markup
|
|
is ever interpreted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from reportlab.lib.colors import Color
|
|
from reportlab.lib.utils import ImageReader, simpleSplit
|
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
|
from reportlab.pdfgen.canvas import Canvas
|
|
|
|
from .style import AMBER, AMBER_BG, CW, GREY, LINE, NAVY, PAGE_H, PAGE_W, PESCO_NAME, TEAL, WHITE, L, R, clean
|
|
|
|
LOGO_PATH = Path(__file__).parent / "assets" / "logo_color.png"
|
|
LOGO_W = 132.0
|
|
LOGO_H = 132.0 / 3.5
|
|
|
|
TABLE_TOP = 118.0 # autotable margin top, where a table continues after a page break
|
|
TABLE_BOTTOM = 60.0 # autotable margin bottom
|
|
BODY_TOP = 124.0 # where ensure_space starts a new page
|
|
SPACE_LIMIT = 740.0
|
|
LINE_FACTOR = 1.15
|
|
PAD_V = 4.2
|
|
PAD_H = 4.0
|
|
# The old tables wrapped cells with jsPDF's splitTextToSize. autotable gives it the cell width less the padding plus
|
|
# 1 pt, and jsPDF's Helvetica widths run about 0.8 % narrower than the AFM widths reportlab measures with. Both are
|
|
# kept here so a cell breaks where the old book broke it ("10/18/2026 11:32 AM" stays on one line in 86 pt).
|
|
WRAP_SLACK = 1.0
|
|
JSPDF_WIDTH = 0.992
|
|
|
|
|
|
def rgb(c: Sequence[int]) -> Color:
|
|
return Color(c[0] / 255, c[1] / 255, c[2] / 255)
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _logo() -> ImageReader | None:
|
|
try:
|
|
return ImageReader(io.BytesIO(LOGO_PATH.read_bytes()))
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def font_of(bold: bool = False, mono: bool = False) -> str:
|
|
if mono:
|
|
return "Courier-Bold" if bold else "Courier"
|
|
return "Helvetica-Bold" if bold else "Helvetica"
|
|
|
|
|
|
def split_lines(text: Any, font: str, size: float, width: float) -> list[str]:
|
|
"""The text wrapped to `width`, keeping its own line breaks. An empty text is one empty line."""
|
|
out: list[str] = []
|
|
for part in clean(text).split("\n"):
|
|
out += simpleSplit(part, font, size, max(width, 1.0)) or [""]
|
|
return out or [""]
|
|
|
|
|
|
class Doc:
|
|
"""A letter document drawn page by page. The header (logo, title, sub, rule) and the foot line are drawn when a
|
|
page starts, so every page carries them (the old decorate()). Page numbers and stamps are added after assembly
|
|
as an overlay (stamps.py)."""
|
|
|
|
def __init__(self, header: tuple[str, str] | None = None, *, foot: bool = True) -> None:
|
|
self.buf = io.BytesIO()
|
|
self.c = Canvas(self.buf, pagesize=(PAGE_W, PAGE_H), pageCompression=1)
|
|
self.c.setAuthor("PESCO - Process Equipment & Service Company, Inc.")
|
|
self.header = header
|
|
self.foot = foot
|
|
self.pages = 1
|
|
self.on_page: Callable[[Doc], None] | None = None
|
|
self._decorate()
|
|
|
|
# ------------------------------------------------------------ pages
|
|
|
|
def _decorate(self) -> None:
|
|
if self.header is not None:
|
|
self.page_header(*self.header)
|
|
if self.foot:
|
|
self.foot_left()
|
|
if self.on_page is not None:
|
|
self.on_page(self)
|
|
|
|
def add_page(self) -> None:
|
|
self.c.showPage()
|
|
self.pages += 1
|
|
self._decorate()
|
|
|
|
def set_header(self, title: str, sub: str) -> None:
|
|
"""The header for the pages started from now on."""
|
|
self.header = (title, sub)
|
|
|
|
def finish(self) -> bytes:
|
|
self.c.showPage()
|
|
self.c.save()
|
|
return self.buf.getvalue()
|
|
|
|
# ------------------------------------------------------------ primitives
|
|
|
|
def Y(self, y: float) -> float:
|
|
"""A top-down y as reportlab's bottom-up y."""
|
|
return PAGE_H - y
|
|
|
|
def tx(
|
|
self,
|
|
s: Any,
|
|
x: float,
|
|
y: float,
|
|
*,
|
|
size: float = 9,
|
|
bold: bool = False,
|
|
color: Sequence[int] = NAVY,
|
|
align: str = "left",
|
|
cs: float = 0,
|
|
max_width: float | None = None,
|
|
mono: bool = False,
|
|
) -> int:
|
|
"""Text at baseline y. With max_width it wraps (line height 1.15). Returns the number of lines drawn."""
|
|
font = font_of(bold, mono)
|
|
c = self.c
|
|
c.setFont(font, size)
|
|
c.setFillColor(rgb(color))
|
|
lines = split_lines(s, font, size, max_width) if max_width else clean(s).split("\n")
|
|
for i, line in enumerate(lines):
|
|
yy = self.Y(y + i * size * LINE_FACTOR)
|
|
w = stringWidth(line, font, size) + cs * max(0, len(line) - 1)
|
|
if align == "right":
|
|
xx = x - w
|
|
elif align == "center":
|
|
xx = x - w / 2
|
|
else:
|
|
xx = x
|
|
if cs:
|
|
c.drawString(xx, yy, line, charSpace=cs)
|
|
else:
|
|
c.drawString(xx, yy, line)
|
|
return len(lines)
|
|
|
|
def lbl(
|
|
self,
|
|
s: Any,
|
|
x: float,
|
|
y: float,
|
|
*,
|
|
size: float = 6.5,
|
|
cs: float = 1.6,
|
|
color: Sequence[int] = NAVY,
|
|
align: str = "left",
|
|
) -> None:
|
|
"""A label: bold tracked capitals."""
|
|
self.tx(clean(s).upper(), x, y, size=size, bold=True, cs=cs, color=color, align=align)
|
|
|
|
def line(self, x1: float, y1: float, x2: float, y2: float, color: Sequence[int], width: float) -> None:
|
|
c = self.c
|
|
c.setStrokeColor(rgb(color))
|
|
c.setLineWidth(width)
|
|
c.line(x1, self.Y(y1), x2, self.Y(y2))
|
|
|
|
def rect(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
w: float,
|
|
h: float,
|
|
*,
|
|
fill: Sequence[int] | None = None,
|
|
stroke: Sequence[int] | None = None,
|
|
width: float = 0.8,
|
|
) -> None:
|
|
"""A rectangle whose top-left corner is (x, y)."""
|
|
c = self.c
|
|
if fill is not None:
|
|
c.setFillColor(rgb(fill))
|
|
if stroke is not None:
|
|
c.setStrokeColor(rgb(stroke))
|
|
c.setLineWidth(width)
|
|
c.rect(x, self.Y(y + h), w, h, stroke=1 if stroke is not None else 0, fill=1 if fill is not None else 0)
|
|
|
|
def rule(self, y: float) -> None:
|
|
self.line(L, y, R, y, NAVY, 1.2)
|
|
self.line(L, y + 3.2, L + CW * 0.26, y + 3.2, TEAL, 2.6)
|
|
|
|
def hair(self, y: float, x1: float = L, x2: float = R) -> None:
|
|
self.line(x1, y, x2, y, LINE, 0.6)
|
|
|
|
def logo(self) -> None:
|
|
img = _logo()
|
|
if img is not None:
|
|
self.c.drawImage(img, L, self.Y(34 + LOGO_H), LOGO_W, LOGO_H, mask="auto")
|
|
|
|
def page_header(self, title: str, sub: str) -> None:
|
|
self.logo()
|
|
self.tx(clean(title).upper(), R, 62, size=20, bold=True, cs=2.6, align="right")
|
|
self.tx(clean(sub).upper(), R, 76, size=6.5, cs=1.6, color=GREY, align="right")
|
|
self.rule(94)
|
|
|
|
def foot_left(self) -> None:
|
|
self.hair(758)
|
|
self.tx(PESCO_NAME, L, 772, size=7, color=GREY)
|
|
|
|
# ------------------------------------------------------------ flow
|
|
|
|
def ensure_space(self, y: float, need: float) -> float:
|
|
if y + need > SPACE_LIMIT:
|
|
self.add_page()
|
|
return BODY_TOP
|
|
return y
|
|
|
|
def section_label(self, s: str, y: float) -> float:
|
|
y = self.ensure_space(y, 60)
|
|
self.lbl(s, L, y)
|
|
return y + 8
|
|
|
|
def note_box(self, text: str, y: float, warn: bool = False) -> float:
|
|
size = 8.0
|
|
lines = split_lines(text, "Helvetica", size, CW - 24)
|
|
h = len(lines) * 10.5 + 16
|
|
y = self.ensure_space(y, h + 8)
|
|
if warn:
|
|
self.rect(L, y, CW, h, fill=AMBER_BG, stroke=AMBER, width=0.8)
|
|
else:
|
|
self.rect(L, y, CW, h, stroke=LINE, width=0.8)
|
|
self.c.setFont("Helvetica", size)
|
|
self.c.setFillColor(rgb(AMBER if warn else NAVY))
|
|
for i, ln in enumerate(lines):
|
|
self.c.drawString(L + 12, self.Y(y + 15 + i * size * LINE_FACTOR), ln)
|
|
return y + h + 10
|
|
|
|
# ------------------------------------------------------------ tables
|
|
|
|
def table(
|
|
self,
|
|
head: Sequence[str] | None,
|
|
body: Sequence[Sequence[Any]],
|
|
start_y: float,
|
|
cols: Mapping[int, Mapping[str, Any]] | None = None,
|
|
*,
|
|
size: float = 8.3,
|
|
head_size: float = 6.3,
|
|
) -> float:
|
|
"""The autotable look: navy 8.3 pt body, the head 6.3 pt bold grey over a 0.9 pt navy rule, hairlines
|
|
between rows, cell padding 4.2 and 4. Cells wrap; the head repeats after a page break. A cell may be a dict
|
|
{"content", "colspan", "bold", "fill", "size", "color"}. Returns the y under the table."""
|
|
cols = cols or {}
|
|
ncols = len(head) if head else 0
|
|
for row in body:
|
|
ncols = max(ncols, sum(int(c.get("colspan", 1)) if isinstance(c, Mapping) else 1 for c in row))
|
|
if ncols == 0:
|
|
return start_y
|
|
widths = self._widths(head, body, cols, ncols, size)
|
|
y = start_y
|
|
limit = PAGE_H - TABLE_BOTTOM
|
|
|
|
head_cells = [{"content": h, "bold": True, "size": head_size, "color": GREY} for h in head] if head else None
|
|
head_h = self._row_height(head_cells, widths, cols, head_size) if head_cells else 0.0
|
|
|
|
def draw_head(yy: float) -> float:
|
|
if not head_cells:
|
|
return yy
|
|
self._draw_row(head_cells, widths, cols, yy, head_h, head_size, head=True)
|
|
self.line(L, yy + head_h, L + sum(widths), yy + head_h, NAVY, 0.9)
|
|
return yy + head_h
|
|
|
|
first_h = self._row_height(body[0], widths, cols, size) if body else 0.0
|
|
if y + head_h + first_h > limit:
|
|
self.add_page()
|
|
y = TABLE_TOP
|
|
y = draw_head(y)
|
|
for row in body:
|
|
h = self._row_height(row, widths, cols, size)
|
|
if y + h > limit:
|
|
self.add_page()
|
|
y = draw_head(TABLE_TOP)
|
|
self._draw_row(row, widths, cols, y, h, size)
|
|
self.line(L, y + h, L + sum(widths), y + h, LINE, 0.5)
|
|
y += h
|
|
return y
|
|
|
|
def kv_table(self, rows: Sequence[tuple[str, Any]], start_y: float) -> float:
|
|
body = [[k, v if (v is not None and str(v).strip() != "") else "—"] for k, v in rows]
|
|
return self.table(None, body, start_y, {0: {"w": CW * 0.36, "color": GREY}, 1: {"bold": True}}, size=8.5)
|
|
|
|
# internals
|
|
|
|
@staticmethod
|
|
def _cells(row: Sequence[Any]) -> list[tuple[int, int, dict[str, Any]]]:
|
|
"""(first column, span, cell) per cell."""
|
|
out: list[tuple[int, int, dict[str, Any]]] = []
|
|
col = 0
|
|
for c in row:
|
|
cell = dict(c) if isinstance(c, Mapping) else {"content": c}
|
|
span = int(cell.get("colspan", 1))
|
|
out.append((col, span, cell))
|
|
col += span
|
|
return out
|
|
|
|
@staticmethod
|
|
def _font(cell: Mapping[str, Any], colspec: Mapping[str, Any]) -> str:
|
|
bold = cell.get("bold", colspec.get("bold", False))
|
|
return font_of(bool(bold), bool(cell.get("mono", colspec.get("mono", False))))
|
|
|
|
def _widths(
|
|
self,
|
|
head: Sequence[str] | None,
|
|
body: Sequence[Sequence[Any]],
|
|
cols: Mapping[int, Mapping[str, Any]],
|
|
ncols: int,
|
|
size: float,
|
|
) -> list[float]:
|
|
fixed = {i: float(cols[i]["w"]) for i in range(ncols) if i in cols and cols[i].get("w")}
|
|
free = [i for i in range(ncols) if i not in fixed]
|
|
widths = [fixed.get(i, 0.0) for i in range(ncols)]
|
|
if not free:
|
|
return widths
|
|
avail = max(CW - sum(fixed.values()), 20.0 * len(free))
|
|
natural = dict.fromkeys(free, 2 * PAD_H + 10)
|
|
rows: list[Sequence[Any]] = [list(head)] if head else []
|
|
rows += list(body)
|
|
for row in rows:
|
|
for col, span, cell in self._cells(row):
|
|
if span != 1 or col not in natural:
|
|
continue
|
|
spec = cols.get(col, {})
|
|
font = self._font(cell, spec)
|
|
sz = float(cell.get("size", spec.get("size", size)))
|
|
for part in clean(cell.get("content")).split("\n"):
|
|
natural[col] = max(natural[col], stringWidth(part, font, sz) + 2 * PAD_H)
|
|
total = sum(natural.values())
|
|
for i in free:
|
|
widths[i] = avail * natural[i] / total if total else avail / len(free)
|
|
# Keep every free column at least 28 pt by taking from the widest ones.
|
|
short = [i for i in free if widths[i] < 28]
|
|
if short and len(short) < len(free):
|
|
need = sum(28 - widths[i] for i in short)
|
|
wide = [i for i in free if i not in short]
|
|
wide_total = sum(widths[i] for i in wide)
|
|
for i in short:
|
|
widths[i] = 28
|
|
for i in wide:
|
|
widths[i] -= need * widths[i] / wide_total
|
|
return widths
|
|
|
|
def _cell_lines(
|
|
self, cell: Mapping[str, Any], spec: Mapping[str, Any], width: float, size: float
|
|
) -> tuple[list[str], str, float]:
|
|
font = self._font(cell, spec)
|
|
sz = float(cell.get("size", spec.get("size", size)))
|
|
room = (width - 2 * PAD_H + WRAP_SLACK) / JSPDF_WIDTH
|
|
return split_lines(cell.get("content"), font, sz, room), font, sz
|
|
|
|
def _row_height(
|
|
self, row: Sequence[Any], widths: Sequence[float], cols: Mapping[int, Mapping[str, Any]], size: float
|
|
) -> float:
|
|
h = 0.0
|
|
for col, span, cell in self._cells(row):
|
|
w = sum(widths[col : col + span])
|
|
lines, _font, sz = self._cell_lines(cell, cols.get(col, {}), w, size)
|
|
h = max(h, len(lines) * sz * LINE_FACTOR + 2 * PAD_V)
|
|
return h
|
|
|
|
def _draw_row(
|
|
self,
|
|
row: Sequence[Any],
|
|
widths: Sequence[float],
|
|
cols: Mapping[int, Mapping[str, Any]],
|
|
y: float,
|
|
h: float,
|
|
size: float,
|
|
head: bool = False,
|
|
) -> None:
|
|
c = self.c
|
|
for col, span, cell in self._cells(row):
|
|
spec = {} if head else cols.get(col, {}) # autotable column styles style the body only: heads sit left
|
|
x = L + sum(widths[:col])
|
|
w = sum(widths[col : col + span])
|
|
if cell.get("fill") is not None:
|
|
self.rect(x, y, w, h, fill=cell["fill"])
|
|
lines, font, sz = self._cell_lines(cell, spec, w, size)
|
|
lh = sz * LINE_FACTOR
|
|
top = y + (h - len(lines) * lh) / 2
|
|
color = cell.get("color", spec.get("color", NAVY))
|
|
c.setFont(font, sz)
|
|
c.setFillColor(rgb(color))
|
|
align = cell.get("align", spec.get("align", "left"))
|
|
for i, ln in enumerate(lines):
|
|
base = top + i * lh + (lh - sz) / 2 + sz * 0.78
|
|
if align == "center":
|
|
c.drawCentredString(x + w / 2, self.Y(base), ln)
|
|
elif align == "right":
|
|
c.drawRightString(x + w - PAD_H, self.Y(base), ln)
|
|
else:
|
|
c.drawString(x + PAD_H, self.Y(base), ln)
|
|
|
|
|
|
def page_x_of_n(tag: str) -> Callable[[int, int], str]:
|
|
"""The page number text of our pages: 'Project P Lot U - Page i of N'."""
|
|
|
|
def text(i: int, n: int) -> str:
|
|
return f"{tag} - Page {i} of {n}"
|
|
|
|
return text
|
|
|
|
|
|
__all__ = ["WHITE", "Doc", "page_x_of_n", "rgb", "split_lines"]
|