Phase 7: close, file, PDFs and prints
ci / frontend (push) Successful in 40s
ci / server (push) Successful in 10m56s
ci / image (push) Successful in 48s
ci / e2e (push) Successful in 2m44s

Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the
feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server
and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book.

Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build
(cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C.
inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP
PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp
signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only
print_log with an audit row for every print and export (migration 0008).

Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History
tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend,
and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 23:49:14 -06:00
co-authored by Claude Fable 5.1
parent 00531cb329
commit 904437153d
59 changed files with 8226 additions and 136 deletions
+408
View File
@@ -0,0 +1,408 @@
"""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
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)))
return split_lines(cell.get("content"), font, sz, width - 2 * PAD_H), 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, {})
if head and col in cols and cols[col].get("align"):
spec = {"align": cols[col]["align"]}
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"]