Files

413 lines
15 KiB
Python
Raw Permalink Normal View History

2026-10-17 23:49:14 -06:00
"""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
2026-10-17 23:49:14 -06:00
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
2026-10-17 23:49:14 -06:00
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
2026-10-17 23:49:14 -06:00
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"]