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