409 lines
15 KiB
Python
409 lines
15 KiB
Python
"""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"]
|