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