| from __future__ import annotations |
|
|
| import math |
| from pathlib import Path |
|
|
|
|
| def esc(text: object) -> str: |
| return str(text).replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") |
|
|
|
|
| def clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float: |
| return max(lo, min(hi, value)) |
|
|
|
|
| def mix(c0: tuple[float, float, float], c1: tuple[float, float, float], t: float) -> tuple[float, float, float]: |
| t = clamp(t) |
| return tuple(c0[i] * (1.0 - t) + c1[i] * t for i in range(3)) |
|
|
|
|
| def text_width(text: str, size: float, bold: bool = False) -> float: |
| width = 0.0 |
| for ch in text: |
| if ch == " ": |
| width += 0.28 |
| elif ch in "ilI.,:;!|": |
| width += 0.25 |
| elif ch in "mwMW@": |
| width += 0.82 |
| elif ch.isupper(): |
| width += 0.62 |
| else: |
| width += 0.52 |
| if bold: |
| width *= 1.05 |
| return width * size |
|
|
|
|
| class PdfCanvas: |
| def __init__(self, width: float = 900, height: float = 560) -> None: |
| self.width = width |
| self.height = height |
| self.ops: list[str] = [] |
|
|
| def _color(self, color: tuple[float, float, float], stroke: bool = False) -> str: |
| return f"{color[0]:.4f} {color[1]:.4f} {color[2]:.4f} {'RG' if stroke else 'rg'}" |
|
|
| def rect( |
| self, |
| x: float, |
| y: float, |
| w: float, |
| h: float, |
| fill: tuple[float, float, float] | None = None, |
| stroke: tuple[float, float, float] | None = None, |
| lw: float = 0.7, |
| ) -> None: |
| if fill is not None and stroke is None: |
| self.ops.append(f"{self._color(fill)} {x:.2f} {y:.2f} {w:.2f} {h:.2f} re f") |
| elif fill is None and stroke is not None: |
| self.ops.append(f"{lw:.2f} w {self._color(stroke, True)} {x:.2f} {y:.2f} {w:.2f} {h:.2f} re S") |
| elif fill is not None and stroke is not None: |
| self.ops.append( |
| f"{lw:.2f} w {self._color(fill)} {self._color(stroke, True)} " |
| f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re B" |
| ) |
|
|
| def line( |
| self, |
| points: list[tuple[float, float]], |
| color: tuple[float, float, float] = (0, 0, 0), |
| lw: float = 1.0, |
| ) -> None: |
| if len(points) < 2: |
| return |
| cmds = [f"{points[0][0]:.2f} {points[0][1]:.2f} m"] |
| cmds.extend(f"{x:.2f} {y:.2f} l" for x, y in points[1:]) |
| self.ops.append(f"{lw:.2f} w {self._color(color, True)} " + " ".join(cmds) + " S") |
|
|
| def text( |
| self, |
| x: float, |
| y: float, |
| text: object, |
| size: float = 9, |
| color: tuple[float, float, float] = (0, 0, 0), |
| align: str = "left", |
| bold: bool = False, |
| ) -> None: |
| s = str(text) |
| w = text_width(s, size, bold) |
| if align == "center": |
| x -= w / 2.0 |
| elif align == "right": |
| x -= w |
| font = "F2" if bold else "F1" |
| self.ops.append( |
| f"BT /{font} {size:.2f} Tf {self._color(color)} " |
| f"1 0 0 1 {x:.2f} {y:.2f} Tm ({esc(s)}) Tj ET" |
| ) |
|
|
| def text_rotated( |
| self, |
| x: float, |
| y: float, |
| text: object, |
| angle_deg: float, |
| size: float = 9, |
| color: tuple[float, float, float] = (0, 0, 0), |
| align: str = "left", |
| bold: bool = False, |
| ) -> None: |
| s = str(text) |
| w = text_width(s, size, bold) |
| angle = math.radians(angle_deg) |
| if align == "center": |
| x -= math.cos(angle) * w / 2.0 |
| y -= math.sin(angle) * w / 2.0 |
| elif align == "right": |
| x -= math.cos(angle) * w |
| y -= math.sin(angle) * w |
| a = math.cos(angle) |
| b = math.sin(angle) |
| c = -math.sin(angle) |
| d = math.cos(angle) |
| font = "F2" if bold else "F1" |
| self.ops.append( |
| f"BT /{font} {size:.2f} Tf {self._color(color)} " |
| f"{a:.5f} {b:.5f} {c:.5f} {d:.5f} {x:.2f} {y:.2f} Tm ({esc(s)}) Tj ET" |
| ) |
|
|
| def save(self, path: Path) -> None: |
| content = "\n".join(self.ops).encode("latin-1", errors="replace") |
| objects: list[bytes] = [ |
| b"<< /Type /Catalog /Pages 2 0 R >>", |
| b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", |
| ( |
| f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.width:.0f} {self.height:.0f}] " |
| f"/Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>" |
| ).encode(), |
| b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", |
| b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", |
| b"<< /Length " + str(len(content)).encode() + b" >>\nstream\n" + content + b"\nendstream", |
| ] |
| out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") |
| offsets = [0] |
| for i, obj in enumerate(objects, start=1): |
| offsets.append(len(out)) |
| out.extend(f"{i} 0 obj\n".encode()) |
| out.extend(obj) |
| out.extend(b"\nendobj\n") |
| xref_pos = len(out) |
| out.extend(f"xref\n0 {len(objects)+1}\n".encode()) |
| out.extend(b"0000000000 65535 f \n") |
| for offset in offsets[1:]: |
| out.extend(f"{offset:010d} 00000 n \n".encode()) |
| out.extend( |
| f"trailer\n<< /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n".encode() |
| ) |
| path.write_bytes(bytes(out)) |
|
|
|
|
| def draw_axes(c: PdfCanvas, x0: float, y0: float, w: float, h: float, ymax: float, ticks: list[float]) -> None: |
| c.line([(x0, y0), (x0, y0 + h)], color=(0.15, 0.15, 0.15), lw=0.8) |
| c.line([(x0, y0), (x0 + w, y0)], color=(0.15, 0.15, 0.15), lw=0.8) |
| for tick in ticks: |
| y = y0 + h * tick / ymax |
| c.line([(x0 - 4, y), (x0 + w, y)], color=(0.86, 0.86, 0.86), lw=0.45) |
| c.text(x0 - 8, y - 3, f"{tick:g}", size=7, color=(0.25, 0.25, 0.25), align="right") |
|
|