415 lines
13 KiB
Python
415 lines
13 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(")", "\\)")
|
|
|
|
|
|
# =============================================================================
|
|
# Standalone PDF Operator Helpers (kept for backward compatibility)
|
|
# =============================================================================
|
|
|
|
def pdf_move_to(x_pt: float, y_pt: float) -> str:
|
|
"""Generate PDF move-to operator."""
|
|
return f"{x_pt:.2f} {y_pt:.2f} m"
|
|
|
|
|
|
def pdf_line_to(x_pt: float, y_pt: float) -> str:
|
|
"""Generate PDF line-to operator."""
|
|
return f"{x_pt:.2f} {y_pt:.2f} l"
|
|
|
|
|
|
def pdf_move_line(x1_pt: float, y1_pt: float, x2_pt: float, y2_pt: float) -> str:
|
|
"""Generate PDF move-to and line-to sequence."""
|
|
return f"{x1_pt:.2f} {y1_pt:.2f} m {x2_pt:.2f} {y2_pt:.2f} l"
|
|
|
|
|
|
def pdf_stroke() -> str:
|
|
"""Generate PDF stroke operator."""
|
|
return "S"
|
|
|
|
|
|
def pdf_text_show(text: str) -> str:
|
|
"""Generate PDF text show operator with escaped text."""
|
|
return f"({escape_pdf_text(text)}) Tj"
|
|
|
|
|
|
def pdf_text_begin() -> str:
|
|
"""Generate PDF begin text operator."""
|
|
return "BT"
|
|
|
|
|
|
def pdf_text_end() -> str:
|
|
"""Generate PDF end text operator."""
|
|
return "ET"
|
|
|
|
|
|
def pdf_set_font(font_name: str, size_pt: float) -> str:
|
|
"""Generate PDF font selection operator."""
|
|
return f"/{font_name} {size_pt:.1f} Tf"
|
|
|
|
|
|
def pdf_set_rgb_color(r: float, g: float, b: float, fill: bool = True) -> str:
|
|
"""
|
|
Generate PDF color setting operator.
|
|
|
|
Args:
|
|
r: Red component (0.0-1.0)
|
|
g: Green component (0.0-1.0)
|
|
b: Blue component (0.0-1.0)
|
|
fill: True for fill color (rg), False for stroke color (RG)
|
|
|
|
Returns:
|
|
PDF color operator string.
|
|
"""
|
|
color_op = "rg" if fill else "RG"
|
|
return f"{r:.3f} {g:.3f} {b:.3f} {color_op}"
|
|
|
|
|
|
def pdf_set_line_width(width_pt: float) -> str:
|
|
"""Generate PDF line width operator."""
|
|
return f"{width_pt:.2f} w"
|
|
|
|
|
|
def pdf_set_line_cap_join(cap: int = 1, join: int = 1) -> str:
|
|
"""
|
|
Generate PDF line cap and join style operators.
|
|
|
|
Args:
|
|
cap: 0=butt, 1=round, 2=projecting square
|
|
join: 0=miter, 1=round, 2=bevel
|
|
|
|
Returns:
|
|
PDF line style operator string.
|
|
"""
|
|
return f"{cap} j {join} J"
|
|
|
|
|
|
def pdf_set_graphics_state(name: str) -> str:
|
|
"""Generate PDF graphics state selection operator."""
|
|
return f"/{name} gs"
|
|
|
|
|
|
def pdf_transform_matrix(a: float, b: float, c: float, d: float, e: float, f: float) -> str:
|
|
"""Generate PDF transformation matrix operator."""
|
|
return f"{a:.2f} {b:.2f} {c:.2f} {d:.2f} {e:.2f} {f:.2f} Tm"
|
|
|
|
|
|
def pdf_arrowhead(
|
|
tip_x_pt: float,
|
|
tip_y_pt: float,
|
|
direction_x: float,
|
|
direction_y: float,
|
|
arrow_len_pt: float,
|
|
arrow_half_w_pt: float,
|
|
) -> str:
|
|
"""
|
|
Generate PDF commands for an arrowhead.
|
|
|
|
Args:
|
|
tip_x_pt: X coordinate of arrow tip
|
|
tip_y_pt: Y coordinate of arrow tip
|
|
direction_x: X direction vector (normalized)
|
|
direction_y: Y direction vector (normalized)
|
|
arrow_len_pt: Length of arrow in points
|
|
arrow_half_w_pt: Half-width of arrow in points
|
|
|
|
Returns:
|
|
PDF commands to draw the arrowhead.
|
|
"""
|
|
# Arrow base (opposite of tip direction)
|
|
base_x = tip_x_pt - direction_x * arrow_len_pt
|
|
base_y = tip_y_pt - direction_y * arrow_len_pt
|
|
|
|
# Perpendicular vectors for arrow wings
|
|
perp_x = direction_y * arrow_half_w_pt
|
|
perp_y = -direction_x * arrow_half_w_pt
|
|
|
|
# Left wing
|
|
left_x = base_x + perp_x
|
|
left_y = base_y + perp_y
|
|
|
|
# Right wing
|
|
right_x = base_x - perp_x
|
|
right_y = base_y - perp_y
|
|
|
|
return (
|
|
f"{tip_x_pt:.2f} {tip_y_pt:.2f} m "
|
|
f"{left_x:.2f} {left_y:.2f} l "
|
|
f"{right_x:.2f} {right_y:.2f} l S"
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# 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_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")
|