286 lines
9.7 KiB
Python
286 lines
9.7 KiB
Python
"""
|
|
PDF Utilities Module.
|
|
Reusable utilities for PDF stream generation, text escaping, and drawing operations.
|
|
Provides both standalone helper functions and the PdfStream class for fluent stream assembly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable
|
|
|
|
import numpy as np
|
|
from numpy.typing import NDArray
|
|
|
|
|
|
# =============================================================================
|
|
# Text Escaping
|
|
# =============================================================================
|
|
|
|
def escape_pdf_text(text: str) -> str:
|
|
"""
|
|
Escape special characters for PDF text strings.
|
|
|
|
In PDF text strings delimited by parentheses, the following characters
|
|
must be escaped with a backslash: backslash, left-paren, right-paren.
|
|
|
|
Non-ASCII characters (like diameter symbol, degree, etc.) are left as-is and will be
|
|
encoded as cp1252 bytes in the PDF stream, matching the WinAnsiEncoding
|
|
used by the embedded font.
|
|
|
|
Args:
|
|
text: The text string to escape.
|
|
|
|
Returns:
|
|
The escaped text string safe for PDF text operators.
|
|
"""
|
|
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# Color type alias
|
|
# =============================================================================
|
|
|
|
Color = tuple[float, float, float]
|
|
|
|
|
|
# =============================================================================
|
|
# PdfStream — Fluent PDF content stream builder
|
|
# =============================================================================
|
|
|
|
class PdfStream:
|
|
"""
|
|
Fluent builder for a PDF page content stream.
|
|
|
|
Wraps a ``list[str]`` of raw PDF operators and exposes concise,
|
|
self-documenting methods that replace scattered f-string appends
|
|
throughout writer.py / sa2_measures.py.
|
|
|
|
Usage::
|
|
|
|
s = PdfStream()
|
|
s.fill_color(0.9, 0.9, 0.9).rect(0, 0, w, h).fill()
|
|
s.stroke_color(0, 0, 0).opacity("GS100").line_width(1.0)
|
|
s.move_to(x0, y0).line_to(x1, y1).stroke()
|
|
s.text("Hello", x, y, font_size=12)
|
|
content = s.encode()
|
|
"""
|
|
|
|
__slots__ = ("_cmds",)
|
|
|
|
def __init__(self) -> None:
|
|
self._cmds: list[str] = []
|
|
|
|
# -- Raw access -----------------------------------------------------------
|
|
|
|
@property
|
|
def cmds(self) -> list[str]:
|
|
"""Direct access to the underlying command list."""
|
|
return self._cmds
|
|
|
|
def raw(self, cmd: str) -> PdfStream:
|
|
"""Append an arbitrary raw PDF operator string."""
|
|
self._cmds.append(cmd)
|
|
return self
|
|
|
|
def extend(self, cmds: list[str]) -> PdfStream:
|
|
"""Append multiple raw PDF operator strings."""
|
|
self._cmds.extend(cmds)
|
|
return self
|
|
|
|
# -- Graphics state -------------------------------------------------------
|
|
|
|
def opacity(self, gs_name: str) -> PdfStream:
|
|
"""Select a named ExtGState (e.g. ``"GS25"``, ``"GS100"``)."""
|
|
self._cmds.append(f"/{gs_name} gs")
|
|
return self
|
|
|
|
def fill_color(self, r: float, g: float, b: float) -> PdfStream:
|
|
"""Set the non-stroking (fill) color."""
|
|
self._cmds.append(f"{r:.3f} {g:.3f} {b:.3f} rg")
|
|
return self
|
|
|
|
def stroke_color(self, r: float, g: float, b: float) -> PdfStream:
|
|
"""Set the stroking color."""
|
|
self._cmds.append(f"{r:.3f} {g:.3f} {b:.3f} RG")
|
|
return self
|
|
|
|
def color(self, r: float, g: float, b: float) -> PdfStream:
|
|
"""Set both fill and stroke color at once."""
|
|
self.fill_color(r, g, b)
|
|
self.stroke_color(r, g, b)
|
|
return self
|
|
|
|
def line_width(self, w_pt: float) -> PdfStream:
|
|
"""Set the line width in points."""
|
|
self._cmds.append(f"{w_pt:.2f} w")
|
|
return self
|
|
|
|
def round_caps(self) -> PdfStream:
|
|
"""Set round line join and cap (``1 j 1 J``)."""
|
|
self._cmds.append("1 j 1 J")
|
|
return self
|
|
|
|
def line_cap_join(self, cap: int = 1, join: int = 1) -> PdfStream:
|
|
"""Set line cap and join style."""
|
|
self._cmds.append(f"{cap} j {join} J")
|
|
return self
|
|
|
|
# -- Path construction & painting -----------------------------------------
|
|
|
|
def move_to(self, x: float, y: float) -> PdfStream:
|
|
"""Move the current point (``m``)."""
|
|
self._cmds.append(f"{x:.2f} {y:.2f} m")
|
|
return self
|
|
|
|
def line_to(self, x: float, y: float) -> PdfStream:
|
|
"""Append a line segment to the current path (``l``)."""
|
|
self._cmds.append(f"{x:.2f} {y:.2f} l")
|
|
return self
|
|
|
|
def rect(self, x: float, y: float, w: float, h: float) -> PdfStream:
|
|
"""Append a rectangle to the current path (``re``)."""
|
|
self._cmds.append(f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re")
|
|
return self
|
|
|
|
def fill(self) -> PdfStream:
|
|
"""Fill the current path (``f``)."""
|
|
self._cmds.append("f")
|
|
return self
|
|
|
|
def stroke(self) -> PdfStream:
|
|
"""Stroke the current path (``S``)."""
|
|
self._cmds.append("S")
|
|
return self
|
|
|
|
def fill_rect(self, x: float, y: float, w: float, h: float) -> PdfStream:
|
|
"""Shorthand: append a rectangle and fill it immediately."""
|
|
self._cmds.append(f"{x:.2f} {y:.2f} {w:.2f} {h:.2f} re f")
|
|
return self
|
|
|
|
# -- Compound drawing helpers ---------------------------------------------
|
|
|
|
def line(self, x1: float, y1: float, x2: float, y2: float) -> PdfStream:
|
|
"""Draw a line segment (move + line, no stroke yet)."""
|
|
self._cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l")
|
|
return self
|
|
|
|
def line_stroke(self, x1: float, y1: float, x2: float, y2: float) -> PdfStream:
|
|
"""Draw a line segment and stroke immediately."""
|
|
self._cmds.append(f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S")
|
|
return self
|
|
|
|
def polyline_stroke(self, *points: tuple[float, float]) -> PdfStream:
|
|
"""Draw an open polyline through *points* and stroke.
|
|
|
|
Each point is an ``(x, y)`` tuple. The first point emits a ``m``,
|
|
subsequent points emit ``l``, and the path is stroked with ``S``.
|
|
"""
|
|
if not points:
|
|
return self
|
|
parts: list[str] = [f"{points[0][0]:.2f} {points[0][1]:.2f} m"]
|
|
for x, y in points[1:]:
|
|
parts.append(f"{x:.2f} {y:.2f} l")
|
|
parts.append("S")
|
|
self._cmds.append(" ".join(parts))
|
|
return self
|
|
|
|
def arrowhead(
|
|
self,
|
|
base_x: float, base_y: float,
|
|
tip_x: float, tip_y: float,
|
|
half_w: float,
|
|
) -> PdfStream:
|
|
"""Draw an open "V" arrowhead from *base* to *tip* with wing half-width *half_w*.
|
|
|
|
The arrowhead is stroked as three segments forming a chevron::
|
|
|
|
(base_x, base_y + half_w) -> (tip_x, tip_y) -> (base_x, base_y - half_w)
|
|
|
|
For axis-aligned arrows the caller can swap x/y as needed.
|
|
"""
|
|
self._cmds.append(
|
|
f"{base_x:.2f} {base_y + half_w:.2f} m "
|
|
f"{tip_x:.2f} {tip_y:.2f} l "
|
|
f"{base_x:.2f} {base_y - half_w:.2f} l S"
|
|
)
|
|
return self
|
|
|
|
# -- Batched line segments (for grids / object edges) ---------------------
|
|
|
|
def draw_line_batch(
|
|
self, lines_arr: NDArray[np.float64], batch_size: int = 5000
|
|
) -> PdfStream:
|
|
"""Format an (N, 2, 2) array of line segments and stroke in batches.
|
|
|
|
Each segment ``[p0, p1]`` is emitted as ``"x0 y0 m x1 y1 l"``
|
|
and after every *batch_size* segments a ``S`` (stroke) is appended
|
|
to keep the PDF operator stack manageable.
|
|
"""
|
|
if len(lines_arr) == 0:
|
|
return self
|
|
p0 = lines_arr[:, 0, :]
|
|
p1 = lines_arr[:, 1, :]
|
|
lines_str = [
|
|
f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l"
|
|
for (x1, y1), (x2, y2) in zip(p0, p1)
|
|
]
|
|
for start in range(0, len(lines_str), batch_size):
|
|
self._cmds.extend(lines_str[start:start + batch_size])
|
|
self._cmds.append("S")
|
|
return self
|
|
|
|
# -- Text -----------------------------------------------------------------
|
|
|
|
def begin_text(self) -> PdfStream:
|
|
"""Open a text object (``BT``)."""
|
|
self._cmds.append("BT")
|
|
return self
|
|
|
|
def end_text(self) -> PdfStream:
|
|
"""Close a text object (``ET``)."""
|
|
self._cmds.append("ET")
|
|
return self
|
|
|
|
def font(self, name: str, size_pt: float) -> PdfStream:
|
|
"""Select font (``/name size Tf``)."""
|
|
self._cmds.append(f"/{name} {size_pt:.1f} Tf")
|
|
return self
|
|
|
|
def text_rendering_mode(self, mode: int = 0) -> PdfStream:
|
|
"""Set text rendering mode (``mode Tr``). 0=fill, 1=stroke, 2=fill+stroke (bold)."""
|
|
self._cmds.append(f"{mode} Tr")
|
|
return self
|
|
|
|
def text_position(self, x: float, y: float) -> PdfStream:
|
|
"""Set the text matrix to translate to (x, y)."""
|
|
self._cmds.append(f"1 0 0 1 {x:.2f} {y:.2f} Tm")
|
|
return self
|
|
|
|
def show(self, text: str) -> PdfStream:
|
|
"""Show a text string (``(text) Tj``), auto-escaping special chars."""
|
|
self._cmds.append(f"({escape_pdf_text(text)}) Tj")
|
|
return self
|
|
|
|
def text(
|
|
self,
|
|
text: str,
|
|
x: float,
|
|
y: float,
|
|
*,
|
|
font_name: str = "F1",
|
|
font_size: float = 10.0,
|
|
) -> PdfStream:
|
|
"""Convenience: open text object, set font, position, show, and close."""
|
|
self.begin_text()
|
|
self.font(font_name, font_size)
|
|
self.text_position(x, y)
|
|
self.show(text)
|
|
self.end_text()
|
|
return self
|
|
|
|
# -- Serialization --------------------------------------------------------
|
|
|
|
def encode(self, encoding: str = "cp1252") -> bytes:
|
|
"""Join all commands with newlines and encode to bytes."""
|
|
content = "\n".join(self._cmds) + "\n"
|
|
return content.encode(encoding, errors="replace")
|