nodeslicer/core/pdf_exporter/pdf_utils.py
2026-08-03 18:18:26 +03:00

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