nodeslicer/core/pdf_exporter/writer.py
2026-07-31 14:25:38 +03:00

590 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
PDF Export Stream Assembly & Writer Module.
Formats vector edge arrays and renders scalable PDF files with grid overlays, text labels, and SA2 technical markings.
"""
import math
import os
from pathlib import Path
from typing import Any
import numpy as np
from numpy.typing import NDArray
from .metadata import (
extract_object_label_info,
extract_object_bounding_boxes,
extract_settings_items,
get_first_attribute_value,
)
from .geometry import (
extract_boundary_edges,
extract_outer_contour_edges,
extract_crease_edges,
deduplicate_2d_edges,
)
from .sa2_measures import render_sa2_measures_pdf
from .sizing import (
compute_grid_steps,
compute_effective_stroke_and_font,
LegendSizing,
MM_TO_PT,
)
def escape_pdf_text(text: str) -> str:
"""
Escapes special PDF characters (parentheses, backslashes) and converts
WinAnsiEncoding symbols (Ø -> \330, ° -> \260, × -> \327, ± -> \261, µ -> \265)
into PDF octal escape codes.
"""
res = []
for ch in text:
if ch == "(":
res.append("\\(")
elif ch == ")":
res.append("\\)")
elif ch == "\\":
res.append("\\\\")
else:
code = ord(ch)
if code < 128:
res.append(ch)
elif code <= 255:
res.append(f"\\{code:03o}")
else:
res.append("?")
return "".join(res)
def _format_pdf_lines(lines_arr: NDArray[np.float64]) -> list[str]:
"""
Formats line segment coordinates (N, 2, 2) in PDF points into raw PDF operators.
"""
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)
]
return lines_str
def export_mesh_to_pdf(
edges_2d: NDArray[np.float64],
filepath: str | Path,
draw_wireframe: bool = True,
draw_boundary: bool = False,
draw_contour: bool = False,
draw_crease: bool = False,
draw_bbox: bool = False,
draw_sa2_holes: bool = False,
draw_sa2_arcs: bool = False,
draw_sa2_chamfers: bool = False,
draw_sa2_step_features: bool = False,
show_name: bool = False,
show_bbox_size: bool = False,
crease_angle: float = 30.0,
margin_mm: float = 10.0,
line_width_mm: float = 0.5,
font_size_mm: float = 0.0,
stroke_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
bg_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
bbox_color: tuple[float, float, float] = (0.0, 0.0, 1.0),
raw_geometry: Any = None,
settings: Any = None,
style: str | None = None,
**kwargs: Any
) -> None:
"""
Exports 2D vector geometry to a 1:1 true physical scale vector PDF file.
Supports toggleable line layers: Wireframe, Boundary (1 face), Contour, Crease Angle.
"""
# Handle legacy style argument if provided
if style == "LINEART":
draw_wireframe = False
draw_boundary = True
draw_crease = True
elif style == "OUTER_CONTOUR":
draw_wireframe = False
draw_contour = True
edge_list: list[NDArray[np.float64]] = []
if draw_wireframe and len(edges_2d) > 0:
edge_list.append(edges_2d)
# extract_crease_edges (lineart) already extracts boundary edges, so skip extract_boundary_edges when draw_crease is active
if draw_boundary and not draw_crease and raw_geometry is not None:
b_edges = extract_boundary_edges(raw_geometry)
if len(b_edges) > 0:
edge_list.append(b_edges)
if draw_contour and raw_geometry is not None:
c_edges = extract_outer_contour_edges(raw_geometry)
if len(c_edges) > 0:
edge_list.append(c_edges)
if draw_crease and raw_geometry is not None:
cr_edges = extract_crease_edges(raw_geometry, max_angle_deg=crease_angle)
if len(cr_edges) > 0:
edge_list.append(cr_edges)
if edge_list:
combined = np.concatenate(edge_list, axis=0)
edges_to_draw = deduplicate_2d_edges(combined)
elif draw_wireframe:
edges_to_draw = edges_2d
else:
edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
target_edges = edges_to_draw if len(edges_to_draw) > 0 else edges_2d
if len(target_edges) > 0:
pts_2d = target_edges.reshape(-1, 2)
min_x, max_x = float(pts_2d[:, 0].min()), float(pts_2d[:, 0].max())
min_y, max_y = float(pts_2d[:, 1].min()), float(pts_2d[:, 1].max())
else:
min_x = max_x = min_y = max_y = 0.0
w_geom = max_x - min_x
h_geom = max_y - min_y
center_x = (min_x + max_x) / 2.0
center_y = (min_y + max_y) / 2.0
main_step, minor_step = compute_grid_steps(w_geom, h_geom)
if len(edges_to_draw) > 0 or len(edges_2d) > 0:
pad_x = minor_step
pad_y = minor_step
# Calculate required grid span with padding
span_x = max(abs(max_x - center_x), abs(min_x - center_x)) + pad_x
span_y = max(abs(max_y - center_y), abs(min_y - center_y)) + pad_y
# Round up to nearest main_step
half_span_x = math.ceil(span_x / main_step) * main_step
half_span_y = math.ceil(span_y / main_step) * main_step
if half_span_x == 0:
half_span_x = main_step
if half_span_y == 0:
half_span_y = main_step
# Ensure grid is symmetric around center
grid_min_x = center_x - half_span_x
grid_max_x = center_x + half_span_x
grid_min_y = center_y - half_span_y
grid_max_y = center_y + half_span_y
# Ensure grid aligns with main_step grid lines
grid_min_x = math.floor(grid_min_x / main_step) * main_step
grid_max_x = math.ceil(grid_max_x / main_step) * main_step
grid_min_y = math.floor(grid_min_y / main_step) * main_step
grid_max_y = math.ceil(grid_max_y / main_step) * main_step
if grid_min_x == grid_max_x:
grid_max_x += main_step
if grid_min_y == grid_max_y:
grid_max_y += main_step
else:
grid_min_x, grid_max_x = -50.0, 50.0
grid_min_y, grid_max_y = -50.0, 50.0
w_grid_mm = grid_max_x - grid_min_x
h_grid_mm = grid_max_y - grid_min_y
total_margin_mm = margin_mm + 2.0 * minor_step
w_total_mm = w_grid_mm + 2.0 * total_margin_mm
h_total_mm = h_grid_mm + 2.0 * total_margin_mm
page_w_pt = w_total_mm * MM_TO_PT
page_h_pt = h_total_mm * MM_TO_PT
line_width_pt, font_size_pt = compute_effective_stroke_and_font(
line_width_mm, font_size_mm, minor_step
)
def to_pdf_pt(pt_mm: NDArray[np.float64]) -> NDArray[np.float64]:
x_pt = (pt_mm[:, 0] - grid_min_x + total_margin_mm) * MM_TO_PT
y_pt = (pt_mm[:, 1] - grid_min_y + total_margin_mm) * MM_TO_PT
return np.column_stack([x_pt, y_pt])
max_grid_steps = 2000
# Vectorized vertical grid lines
k_x_start = int(np.round(grid_min_x / minor_step))
k_x_end = int(np.round(grid_max_x / minor_step))
k_x = np.arange(k_x_start, k_x_end + 1)
if len(k_x) <= max_grid_steps:
gx = k_x * minor_step
pts0_v = np.column_stack([gx, np.full_like(gx, grid_min_y)])
pts1_v = np.column_stack([gx, np.full_like(gx, grid_max_y)])
p0_v = to_pdf_pt(pts0_v)
p1_v = to_pdf_pt(pts1_v)
is_main_x = (k_x % 10 == 0)
main_v_lines = np.column_stack([p0_v[is_main_x], p1_v[is_main_x]]).reshape(-1, 2, 2)
minor_v_lines = np.column_stack([p0_v[~is_main_x], p1_v[~is_main_x]]).reshape(-1, 2, 2)
else:
main_v_lines = np.empty((0, 2, 2))
minor_v_lines = np.empty((0, 2, 2))
# Vectorized horizontal grid lines
k_y_start = int(np.round(grid_min_y / minor_step))
k_y_end = int(np.round(grid_max_y / minor_step))
k_y = np.arange(k_y_start, k_y_end + 1)
if len(k_y) <= max_grid_steps:
gy = k_y * minor_step
pts0_h = np.column_stack([np.full_like(gy, grid_min_x), gy])
pts1_h = np.column_stack([np.full_like(gy, grid_max_x), gy])
p0_h = to_pdf_pt(pts0_h)
p1_h = to_pdf_pt(pts1_h)
is_main_y = (k_y % 10 == 0)
main_h_lines = np.column_stack([p0_h[is_main_y], p1_h[is_main_y]]).reshape(-1, 2, 2)
minor_h_lines = np.column_stack([p0_h[~is_main_y], p1_h[~is_main_y]]).reshape(-1, 2, 2)
else:
main_h_lines = np.empty((0, 2, 2))
minor_h_lines = np.empty((0, 2, 2))
main_lines_arr = np.concatenate([main_v_lines, main_h_lines], axis=0) if len(main_v_lines) or len(main_h_lines) else np.empty((0, 2, 2))
minor_lines_arr = np.concatenate([minor_v_lines, minor_h_lines], axis=0) if len(minor_v_lines) or len(minor_h_lines) else np.empty((0, 2, 2))
stream_cmds: list[str] = []
# 1. Solid background fill
br, bg, bb = bg_color
sr, sg, sb = stroke_color
stream_cmds.append(f"{br:.3f} {bg:.3f} {bb:.3f} rg")
stream_cmds.append(f"0 0 {page_w_pt:.2f} {page_h_pt:.2f} re f")
# 2. Wireframe stroke color
stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} RG")
# 3. Minor Grid Lines (25% Opacity)
if len(minor_lines_arr) > 0:
stream_cmds.append("/GS25 gs")
minor_grid_pt = min(0.3 * MM_TO_PT, line_width_pt)
stream_cmds.append(f"{minor_grid_pt:.2f} w")
lines_str = _format_pdf_lines(minor_lines_arr)
for start_idx in range(0, len(lines_str), 5000):
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S")
# 4. Main Grid Lines (50% Opacity)
if len(main_lines_arr) > 0:
stream_cmds.append("/GS50 gs")
main_grid_pt = min(0.4 * MM_TO_PT, line_width_pt)
stream_cmds.append(f"{main_grid_pt:.2f} w")
lines_str = _format_pdf_lines(main_lines_arr)
for start_idx in range(0, len(lines_str), 5000):
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S")
# 5. Object Lines (100% Opacity)
if len(edges_to_draw) > 0:
stream_cmds.append("/GS100 gs")
stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J") # round join and cap
p0_pt = to_pdf_pt(edges_to_draw[:, 0, :])
p1_pt = to_pdf_pt(edges_to_draw[:, 1, :])
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(obj_lines_arr)
for start_idx in range(0, len(lines_str), 5000):
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S")
# 5.5. Object Bounding Boxes (100% Opacity in bbox_color)
if draw_bbox and raw_geometry is not None:
bbox_edges = extract_object_bounding_boxes(raw_geometry)
if len(bbox_edges) > 0:
b_r, b_g, b_b = bbox_color
stream_cmds.append("/GS100 gs")
stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J")
stream_cmds.append(f"{b_r:.3f} {b_g:.3f} {b_b:.3f} RG")
p0_pt = to_pdf_pt(bbox_edges[:, 0, :])
p1_pt = to_pdf_pt(bbox_edges[:, 1, :])
bbox_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
lines_str = _format_pdf_lines(bbox_lines_arr)
for start_idx in range(0, len(lines_str), 5000):
stream_cmds.extend(lines_str[start_idx:start_idx + 5000])
stream_cmds.append("S")
# 5.6. SA2 Automated Technical Measures Block
if raw_geometry is not None and (draw_sa2_holes or draw_sa2_arcs or draw_sa2_chamfers or draw_sa2_step_features):
render_sa2_measures_pdf(
stream_cmds,
raw_geometry,
draw_sa2_holes=draw_sa2_holes,
draw_sa2_arcs=draw_sa2_arcs,
draw_sa2_chamfers=draw_sa2_chamfers,
draw_sa2_step_features=draw_sa2_step_features,
minor_step_mm=minor_step,
main_step=main_step,
to_pdf_pt=to_pdf_pt,
font_size_pt=font_size_pt,
line_width_pt=line_width_pt,
draw_wireframe=draw_wireframe,
draw_boundary=draw_boundary,
draw_contour=draw_contour,
draw_crease=draw_crease,
crease_angle=crease_angle,
)
# 6. Size Scale Legend (Bottom Left, aligned directly on bottom-left main grid lines with arrows and X/Y labels)
stream_cmds.append("/GS100 gs")
stream_cmds.append("0.000 0.000 0.000 RG")
stream_cmds.append("0.000 0.000 0.000 rg")
leg_sizing = LegendSizing(main_step, minor_step, line_width_pt, font_size_pt)
x_grid_start_mm = grid_min_x
x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step)
num_main_steps = int(np.round((x_grid_end_mm - x_grid_start_mm) / main_step))
p_start_pt = to_pdf_pt(np.array([[x_grid_start_mm, grid_min_y]]))
p_end_pt = to_pdf_pt(np.array([[x_grid_end_mm, grid_min_y]]))
x_start_pt = p_start_pt[0, 0]
x_end_pt = p_end_pt[0, 0]
y_bar_pt = p_start_pt[0, 1]
# Vertical Y-axis extension dynamically sized past tick height
y_grid_top_mm = grid_min_y + leg_sizing.y_axis_height_mm
p_top_pt = to_pdf_pt(np.array([[grid_min_x, y_grid_top_mm]]))
y_top_pt = p_top_pt[0, 1]
arrow_len_pt = leg_sizing.arrow_len_pt
arrow_half_w_pt = leg_sizing.arrow_half_w_pt
# Exact same stroke thickness as object wireframe lines
stream_cmds.append(f"{line_width_pt:.2f} w")
stream_cmds.append("1 j 1 J")
# 6a. Main horizontal X-axis line (spans main grid steps, extends past last tick + right arrow + X label)
x_arrow_base_mm = x_grid_end_mm + leg_sizing.x_axis_extension_mm
p_arrow_base_pt = to_pdf_pt(np.array([[x_arrow_base_mm, grid_min_y]]))
x_arrow_base_pt = p_arrow_base_pt[0, 0]
x_arrow_tip_pt = x_arrow_base_pt + arrow_len_pt
# Horizontal main scale bar line
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l S")
# Pointy arrowhead (+X) extending past last tick
stream_cmds.append(
f"{x_arrow_base_pt:.2f} {y_bar_pt + arrow_half_w_pt:.2f} m "
f"{x_arrow_tip_pt:.2f} {y_bar_pt:.2f} l "
f"{x_arrow_base_pt:.2f} {y_bar_pt - arrow_half_w_pt:.2f} l S"
)
# Ticks directly overlapping vertical main grid lines on X-axis
tick_cmds = []
for k in range(num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0]
tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
tick_h_pt = tick_h_mm * MM_TO_PT
tick_cmds.append(f"{xk_pt:.2f} {y_bar_pt:.2f} m {xk_pt:.2f} {y_bar_pt + tick_h_pt:.2f} l")
stream_cmds.extend(tick_cmds)
stream_cmds.append("S")
# 6b. Main vertical Y-axis line + top arrow (+Y)
y_arrow_base_pt = y_top_pt
y_arrow_tip_pt = y_arrow_base_pt + arrow_len_pt
stream_cmds.append(f"{x_start_pt:.2f} {y_bar_pt:.2f} m {x_start_pt:.2f} {y_arrow_tip_pt:.2f} l S")
# Pointy arrowhead (+Y) extending past y_top_pt
stream_cmds.append(
f"{x_start_pt - arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} m "
f"{x_start_pt:.2f} {y_arrow_tip_pt:.2f} l "
f"{x_start_pt + arrow_half_w_pt:.2f} {y_arrow_base_pt:.2f} l S"
)
# 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label
stream_cmds.append("0.000 0.000 0.000 rg")
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf")
for k in range(1, num_main_steps + 1):
xk_mm = x_grid_start_mm + k * main_step
pk_pt = to_pdf_pt(np.array([[xk_mm, grid_min_y]]))
xk_pt = pk_pt[0, 0]
tick_h_mm = leg_sizing.major_tick_mm if k in (0, 5, num_main_steps) else leg_sizing.minor_tick_mm
tick_h_pt = tick_h_mm * MM_TO_PT
val_mm = k * main_step
if main_step >= 1000.0:
val_num = val_mm / 1000.0
unit = " m" if k == num_main_steps else ""
tick_str = f"{val_num:g}{unit}"
else:
unit = " mm" if k == num_main_steps else ""
tick_str = f"{val_mm:g}{unit}"
tw_pt = len(tick_str) * font_size_pt * 0.60
x_text_pt = xk_pt - (tw_pt / 2.0)
y_text_pt = y_bar_pt + tick_h_pt + (2.0 * MM_TO_PT)
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {y_text_pt:.2f} Tm")
stream_cmds.append(f"({escape_pdf_text(tick_str)}) Tj")
# X axis label next to right arrowhead
x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT)
y_x_label_pt = y_bar_pt - (font_size_pt * 0.35)
stream_cmds.append(f"1 0 0 1 {x_x_label_pt:.2f} {y_x_label_pt:.2f} Tm")
stream_cmds.append("(X) Tj")
# Y axis label next to top arrowhead
x_y_label_pt = x_start_pt - (font_size_pt * 0.35)
y_y_label_pt = y_arrow_tip_pt + (2.0 * MM_TO_PT)
stream_cmds.append(f"1 0 0 1 {x_y_label_pt:.2f} {y_y_label_pt:.2f} Tm")
stream_cmds.append("(Y) Tj")
stream_cmds.append("ET")
# 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex)
settings_items = extract_settings_items(settings)
if (show_name or show_bbox_size or settings_items) and raw_geometry is not None:
label_infos = extract_object_label_info(raw_geometry)
if label_infos:
eff_font_size_pt = font_size_pt if font_size_pt is not None and font_size_pt > 0 else max(8.0, line_width_pt * 4.0)
line_spacing_pt = eff_font_size_pt * 1.25
stream_cmds.append("/GS100 gs")
for so_item, obj_name, max_x_mm, y_val_mm, w_obj_mm, h_obj_mm in label_infos:
p_pt = to_pdf_pt(np.array([[max_x_mm, y_val_mm]]))
x_text_pt = p_pt[0, 0] + (2.5 * MM_TO_PT)
y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35)
lines_to_draw: list[tuple[str, tuple[float, float, float]]] = []
if show_name:
lines_to_draw.append((str(obj_name), stroke_color))
if show_bbox_size:
if main_step >= 1000.0:
w_val = w_obj_mm / 1000.0
h_val = h_obj_mm / 1000.0
size_str = f"(X: {w_val:g}m Y: {h_val:g}m)"
else:
size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)"
lines_to_draw.append((size_str, bbox_color))
for key_name, suffix in settings_items:
attr_val = get_first_attribute_value(so_item, key_name)
if attr_val is not None:
if isinstance(attr_val, float):
val_str = f"{attr_val:g}"
else:
val_str = str(attr_val)
if suffix:
val_str = f"{val_str}{suffix}"
lines_to_draw.append((f"{key_name}: {val_str}", stroke_color))
if lines_to_draw:
stream_cmds.append("BT")
stream_cmds.append(f"/F1 {eff_font_size_pt:.1f} Tf")
for line_idx, (line_str, col) in enumerate(lines_to_draw):
cr, cg, cb = col
stream_cmds.append(f"{cr:.3f} {cg:.3f} {cb:.3f} rg")
clean_str = escape_pdf_text(line_str)
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
stream_cmds.append(f"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm")
stream_cmds.append(f"({clean_str}) Tj")
stream_cmds.append("ET")
stream_content = "\n".join(stream_cmds) + "\n"
stream_bytes = stream_content.encode("utf-8")
objects: list[bytes] = []
objects.append(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
objects.append(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
page_header = (
f"3 0 obj\n"
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_w_pt:.2f} {page_h_pt:.2f}] "
f"/Resources << /ExtGState << /GS25 5 0 R /GS50 6 0 R /GS100 7 0 R >> /Font << /F1 8 0 R >> >> "
f"/Contents 4 0 R >>\n"
f"endobj\n"
)
objects.append(page_header.encode("utf-8"))
content_header = f"4 0 obj\n<< /Length {len(stream_bytes)} >>\nstream\n".encode("utf-8")
content_footer = b"endstream\nendobj\n"
objects.append(content_header + stream_bytes + content_footer)
objects.append(b"5 0 obj\n<< /Type /ExtGState /CA 0.25 /ca 0.25 >>\nendobj\n")
objects.append(b"6 0 obj\n<< /Type /ExtGState /CA 0.5 /ca 0.5 >>\nendobj\n")
objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n")
# Embed League Mono Typeface
font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf"
if not font_file_path.exists():
font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf"
font_bytes: bytes | None = None
if font_file_path.exists():
try:
with open(font_file_path, "rb") as f:
font_bytes = f.read()
except Exception:
font_bytes = None
if font_bytes:
widths_str = " ".join(["600"] * 224)
objects.append(
f"8 0 obj\n"
f"<< /Type /Font /Subtype /Type1 /BaseFont /LeagueMono-Regular "
f"/FirstChar 32 /LastChar 255 "
f"/Widths [ {widths_str} ] "
f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8")
)
objects.append(
b"9 0 obj\n"
b"<< /Type /FontDescriptor /FontName /LeagueMono-Regular /Flags 33 "
b"/FontBBox [-200 -200 1200 1000] /ItalicAngle 0 /Ascent 1000 /Descent -200 "
b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n"
)
font_stream_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8")
font_stream_footer = b"\nendstream\nendobj\n"
objects.append(font_stream_header + font_bytes + font_stream_footer)
else:
objects.append(b"8 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n")
header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"
parent_dir = os.path.dirname(filepath)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(filepath, "wb") as f:
f.write(header)
offsets = [0]
current_offset = len(header)
for obj_bytes in objects:
offsets.append(current_offset)
f.write(obj_bytes)
current_offset += len(obj_bytes)
xref_offset = current_offset
f.write(b"xref\n")
f.write(f"0 {len(offsets)}\n".encode("utf-8"))
f.write(b"0000000000 65535 f \n")
for off in offsets[1:]:
f.write(f"{off:010d} 00000 n \n".encode("utf-8"))
f.write(b"trailer\n")
f.write(f"<< /Size {len(offsets)} /Root 1 0 R >>\n".encode("utf-8"))
f.write(b"startxref\n")
f.write(f"{xref_offset}\n".encode("utf-8"))
f.write(b"%%EOF\n")
# Alias for backward compatibility
export_wireframe_to_pdf = export_mesh_to_pdf