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