nodeslicer/core/pdf_exporter/pdf_utils.py
2026-10-15 14:13:39 +03:00

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