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
+320
View File
@@ -0,0 +1,320 @@
"""Page rasters for the heat map, the weld map and the VOID stamp, drawn with Pillow.
Ports of the old renderMapCanvas, drawArrow, htDrawCanvas and stampVoidCanvas, and of the new frontend's
lib/heatTable.ts drawHeatTable, lib/voidStamp.ts and lib/geometry.ts bpos. Pillow's built-in sized font stands in
for Arial and Courier New (no font files are shipped); bold is drawn with a thin stroke of the same colour.
"""
from __future__ import annotations
import io
import math
from collections.abc import Iterable, Mapping, Sequence
from functools import lru_cache
from pathlib import Path
from typing import Any
from PIL import Image, ImageDraw, ImageFont
HEAT = (0xB0, 0x78, 0x00)
WELD = (0xC2, 0x18, 0x5B)
HEAT_MISSING = (0xB0, 0x00, 0x20)
HEAT_NA = (0x6B, 0x5A, 0x3A)
INK = (0x2B, 0x1D, 0x00)
HEAD_INK = (0x3D, 0x2A, 0x00)
HEAD_BG = (0xF3, 0xE2, 0xB8)
PILL_BG = (0xFF, 0xF8, 0xE6)
NO_HEAT_W = "MM" # the width of the red dash a missing heat shows (Pillow's font has no em dash glyph)
HT_ROWS = 12
HT_FONT = 0.0075
Font = Any # ImageFont.FreeTypeFont, or the bitmap default when FreeType is missing
@lru_cache(maxsize=256)
def font(size: float) -> Font:
px = max(6, round(size))
try:
return ImageFont.load_default(size=px)
except (TypeError, OSError, ImportError): # Pillow without FreeType: the fixed bitmap font
return ImageFont.load_default()
def text_w(f: Font, s: str) -> float:
try:
return float(f.getlength(s))
except AttributeError:
box = f.getbbox(s)
return float(box[2] - box[0])
def _text(
d: ImageDraw.ImageDraw,
xy: tuple[float, float],
s: str,
f: Font,
fill: Sequence[int],
anchor: str = "mm",
bold: bool = True,
) -> None:
size = getattr(f, "size", 12)
sw = max(1, round(size / 22)) if bold else 0
d.text(xy, s, font=f, fill=tuple(fill), anchor=anchor, stroke_width=sw, stroke_fill=tuple(fill))
# ---------------------------------------------------------------- the page
def page_points(page: Any) -> tuple[float, float]:
"""A pdfium page's MediaBox size in points, after /Rotate."""
try:
llx, lly, urx, ury = page.get_mediabox()
w, h = abs(urx - llx), abs(ury - lly)
except Exception: # an odd box: fall back to pdfium's own size
w, h = page.get_size()
if int(page.get_rotation() or 0) % 180 == 90:
w, h = h, w
return float(w), float(h)
def render_page(
path: Path | str, pn: int, max_px: int = 3400, max_scale: float = 200 / 72
) -> tuple[Image.Image, float, float]:
"""Page `pn` (1-based) of a PDF as an RGB image on white, rotation applied, at most `max_px` on the long side and
at most `max_scale` pixels per point. Returns the image and the page size in points."""
import pypdfium2 as pdfium
doc = pdfium.PdfDocument(str(path))
try:
if pn < 1 or pn > len(doc):
raise ValueError(f"The PDF has no page {pn}.")
page = doc[pn - 1]
try:
w_pt, h_pt = page_points(page)
scale = min(max_scale, max_px / max(w_pt, h_pt, 1.0))
bitmap = page.render(scale=scale, fill_color=(255, 255, 255, 255))
img = bitmap.to_pil().convert("RGB")
finally:
page.close()
finally:
doc.close()
return img, w_pt, h_pt
def to_jpeg(img: Image.Image, quality: int = 90) -> bytes:
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True)
return buf.getvalue()
# ---------------------------------------------------------------- marks
def clamp01(v: float) -> float:
return max(0.01, min(0.99, v))
def bpos(m: Mapping[str, Any]) -> tuple[float, float, float, float]:
"""(fx, fy, bfx, bfy): the arrow point and the bubble. A stored bubble wins; else the old default spot."""
fx = float(m["fx"]) if m.get("fx") is not None else 0.5
fy = float(m["fy"]) if m.get("fy") is not None else 0.5
bfx = float(m["bfx"]) if m.get("bfx") is not None else clamp01(fx + (-0.05 if fx > 0.85 else 0.05))
bfy = float(m["bfy"]) if m.get("bfy") is not None else clamp01(fy + (0.07 if fy < 0.12 else -0.07))
return fx, fy, bfx, bfy
def mark_radius(width: float) -> float:
return max(18.0, width / 150)
def _arrow(
d: ImageDraw.ImageDraw, ax: float, ay: float, bx: float, by: float, size: float, color: Sequence[int]
) -> None:
"""The old drawArrow: a filled head at (ax, ay) pointing away from (bx, by)."""
ang = math.atan2(ay - by, ax - bx)
ca, sa = math.cos(ang), math.sin(ang)
pts = [(0.0, 0.0), (-size * 1.6, -size * 0.6), (-size * 1.6, size * 0.6)]
d.polygon([(ax + x * ca - y * sa, ay + x * sa + y * ca) for x, y in pts], fill=tuple(color))
def _leaders(d: ImageDraw.ImageDraw, marks: Iterable[Mapping[str, Any]], w: int, h: int, color: Sequence[int]) -> None:
r = mark_radius(w)
for m in marks:
fx, fy, bfx, bfy = bpos(m)
ax, ay, bx, by = fx * w, fy * h, bfx * w, bfy * h
d.line([(bx, by), (ax, ay)], fill=tuple(color), width=max(2, round(r / 7)))
_arrow(d, ax, ay, bx, by, r / 2.4, color)
def draw_weld_marks(img: Image.Image, welds: Iterable[Mapping[str, Any]], page_n: int) -> Image.Image:
"""The weld bubbles of one page: the leader to the arrow point, the arrowhead, a white disc with a pink ring of
r/6 and the id W{n} bold in the middle."""
on = [wd for wd in welds if wd.get("page") == page_n]
if not on:
return img
w, h = img.size
d = ImageDraw.Draw(img)
r = mark_radius(w)
_leaders(d, on, w, h, WELD)
f = font(round(r * 0.85))
for wd in on:
_fx, _fy, bfx, bfy = bpos(wd)
x, y = bfx * w, bfy * h
d.ellipse([x - r, y - r, x + r, y + r], fill=(255, 255, 255), outline=WELD, width=max(1, round(r / 6)))
label = f"W{wd.get('weld_no')}"
fl = f if text_w(f, label) <= r * 1.6 else font(r * 0.85 * r * 1.6 / max(text_w(f, label), 1.0))
_text(d, (x, y), label, fl, WELD)
return img
def heat_of(bom: Iterable[Mapping[str, Any]], item: Any) -> str:
for b in bom:
if str(b.get("item")) == str(item):
heat = str(b.get("heat") or "").strip()
return heat or ("N/A" if b.get("na") else "")
return ""
def draw_heat_tags(
img: Image.Image, tags: Iterable[Mapping[str, Any]], bom: Sequence[Mapping[str, Any]], page_n: int
) -> Image.Image:
"""The older heat tags of one page: an amber pill with the item in a filled circle and the heat (or NO HEAT)."""
on = [t for t in tags if t.get("page") == page_n]
if not on:
return img
w, h = img.size
d = ImageDraw.Draw(img)
r = mark_radius(w)
_leaders(d, on, w, h, HEAT)
f_txt = font(round(r * 0.8))
f_item = font(round(r * 0.75))
for t in on:
_fx, _fy, bfx, bfy = bpos(t)
x, y = bfx * w, bfy * h
txt = heat_of(bom, t.get("item")) or "NO HEAT"
tw = text_w(f_txt, txt)
ph = r * 1.7
cr = ph * 0.42
pw = tw + ph + r * 0.9
x0, y0 = x - pw / 2, y - ph / 2
d.rounded_rectangle(
[x0, y0, x0 + pw, y0 + ph], radius=ph / 2, fill=PILL_BG, outline=HEAT, width=max(1, round(r / 6))
)
cx = x0 + ph / 2
d.ellipse([cx - cr, y - cr, cx + cr, y + cr], fill=HEAT)
_text(d, (cx, y), str(t.get("item")), f_item, (255, 255, 255))
_text(d, (x0 + ph + r * 0.2, y), txt, f_txt, HEAD_INK, anchor="lm")
return img
# ---------------------------------------------------------------- the heat table
def heat_rows(bom: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""ITEM and HEAT NO. per BOM line: the heat, N/A on an N/A line, or empty when missing."""
out = []
for b in bom:
heat = str(b.get("heat") or "").strip()
out.append(
{"item": str(b.get("item")), "heat": heat or ("N/A" if b.get("na") else ""), "na": bool(b.get("na"))}
)
return out
def heat_groups(rows: Sequence[Any]) -> list[list[Any]]:
"""At most 12 rows per group, the groups as even as they can be. Always at least one group."""
n = len(rows)
per = math.ceil(n / math.ceil(n / HT_ROWS)) if n > HT_ROWS else HT_ROWS
out = [list(rows[i : i + per]) for i in range(0, n, per)]
return out or [[]]
def draw_heat_table(img: Image.Image, placement: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) -> Image.Image:
"""The heat table on a page raster: HEAT NUMBERS on an amber band, then ITEM | HEAT NO. groups side by side.
Font size W x 0.0075 x s; a missing heat is a red dash, N/A is brown."""
W, H = img.size
s = float(placement.get("s") or 1)
fs = W * HT_FONT * s
pad_x = fs * 0.5
pad_y = fs * 0.2
row_h = fs * 1.15 + pad_y * 2
bw = max(1, round(max(1.5, fs * 0.08)))
groups = heat_groups(rows)
th_f, td_f, cap_f = font(round(fs * 0.8)), font(round(fs)), font(round(fs * 0.85))
widths = []
for g in groups:
wi = max([text_w(th_f, "ITEM")] + [text_w(td_f, r["item"]) for r in g])
wh = max(
[text_w(th_f, "HEAT NO.")] + [text_w(td_f, r["heat"] or NO_HEAT_W) * (1 if r["heat"] else 0.5) for r in g]
)
widths.append((wi + pad_x * 2, wh + pad_x * 2))
total_w = sum(a + b for a, b in widths)
cap_h = row_h
max_rows = max(len(g) for g in groups)
total_h = cap_h + row_h * (max_rows + 1)
x0 = float(placement.get("x") or 0) * W
y0 = float(placement.get("y") or 0) * H
d = ImageDraw.Draw(img)
d.rectangle([x0, y0, x0 + total_w, y0 + total_h], fill=(255, 255, 255))
d.rectangle([x0, y0, x0 + total_w, y0 + cap_h], fill=HEAT)
_text(d, (x0 + total_w / 2, y0 + cap_h / 2), "HEAT NUMBERS", cap_f, (255, 255, 255))
gx = x0
for gi, g in enumerate(groups):
wi, wh = widths[gi]
y = y0 + cap_h
d.rectangle([gx, y, gx + wi + wh, y + row_h], fill=HEAD_BG)
_text(d, (gx + wi / 2, y + row_h / 2), "ITEM", th_f, HEAD_INK)
_text(d, (gx + wi + wh / 2, y + row_h / 2), "HEAT NO.", th_f, HEAD_INK)
d.rectangle([gx, y, gx + wi, y + row_h], outline=HEAT, width=bw)
d.rectangle([gx + wi, y, gx + wi + wh, y + row_h], outline=HEAT, width=bw)
y += row_h
for r in g:
_text(d, (gx + wi / 2, y + row_h / 2), r["item"], td_f, INK)
color = (HEAT_NA if r["na"] else INK) if r["heat"] else HEAT_MISSING
if r["heat"]:
_text(d, (gx + wi + pad_x, y + row_h / 2), r["heat"], td_f, color, anchor="lm")
else:
dash = text_w(td_f, NO_HEAT_W) * 0.5
ym = y + row_h / 2
d.line([(gx + wi + pad_x, ym), (gx + wi + pad_x + dash, ym)], fill=color, width=max(1, round(fs / 10)))
d.rectangle([gx, y, gx + wi, y + row_h], outline=HEAT, width=bw)
d.rectangle([gx + wi, y, gx + wi + wh, y + row_h], outline=HEAT, width=bw)
y += row_h
gx += wi + wh
d.rectangle([x0, y0, x0 + total_w, y0 + total_h], outline=HEAT, width=bw * 2)
return img
# ---------------------------------------------------------------- VOID
def stamp_void(img: Image.Image, text: str) -> Image.Image:
"""A big diagonal VOID at low opacity, the void text under it, and a red border (voidStamp.ts)."""
base = img.convert("RGBA")
w, h = base.size
layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
big = font(round(w / 5.5))
d.text(
(w / 2, h / 2),
"VOID",
font=big,
anchor="mm",
fill=(220, 0, 0, round(255 * 0.3)),
stroke_width=max(4, round(w / 260)),
stroke_fill=(200, 0, 0, round(255 * 0.55)),
)
if text:
small = font(round(w / 55))
d.text((w / 2, h / 2 + w / 9), text, font=small, anchor="mm", fill=(200, 0, 0, round(255 * 0.8)))
layer = layer.rotate(
math.degrees(math.atan2(h, w) * 0.85), center=(w / 2, h / 2), resample=Image.Resampling.BICUBIC
)
out = Image.alpha_composite(base, layer)
lw = max(6, round(w / 200))
border = Image.new("RGBA", (w, h), (0, 0, 0, 0))
ImageDraw.Draw(border).rectangle(
[lw / 2, lw / 2, w - lw / 2, h - lw / 2], outline=(200, 0, 0, round(255 * 0.7)), width=lw
)
out = Image.alpha_composite(out, border)
return out.convert("RGB")