nodeslicer/core/pdf_exporter/writer.py
2026-08-03 18:59:21 +03:00

692 lines
27 KiB
Python

"""
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 datetime
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_unique_top_view_edges,
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,
)
from .pdf_utils import PdfStream, escape_pdf_text
def render_technical_header_table(
s: PdfStream,
page_w_pt: float,
page_h_pt: float,
total_margin_mm: float,
header_info: dict[str, Any],
stroke_color: tuple[float, float, float],
bg_color: tuple[float, float, float],
font_size_pt: float,
line_width_pt: float,
obj_name: str = "",
) -> None:
"""
Renders an ISO/DIN technical drawing header block (title block) at the bottom-right corner.
"""
w_table_mm = font_size_pt/MM_TO_PT * 25.0
cell_height_mm = font_size_pt/MM_TO_PT * 1.4
h_table_mm = cell_height_mm*3
x1_pt = page_w_pt - (total_margin_mm * MM_TO_PT)
y0_pt = total_margin_mm * MM_TO_PT
x0_pt = x1_pt - (w_table_mm * MM_TO_PT)
y1_pt = y0_pt + (h_table_mm * MM_TO_PT)
# Fill background box
br, bg, bb = bg_color
sr, sg, sb = stroke_color
s.opacity("GS100")
s.fill_color(br, bg, bb)
s.fill_rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT)
# Table borders and grid
table_lw_pt = line_width_pt * 1.2
grid_lw_pt = line_width_pt * 0.8
s.stroke_color(sr, sg, sb).line_width(table_lw_pt).round_caps()
s.rect(x0_pt, y0_pt, w_table_mm * MM_TO_PT, h_table_mm * MM_TO_PT).stroke()
h_row_pt = cell_height_mm * MM_TO_PT
y_row1_pt = y0_pt + h_row_pt
y_row2_pt = y0_pt + 2.0 * h_row_pt
s.line_width(grid_lw_pt)
# Horizontal lines
s.line_stroke(x0_pt, y_row1_pt, x1_pt, y_row1_pt)
s.line_stroke(x0_pt, y_row2_pt, x1_pt, y_row2_pt)
# Vertical dividers
x_div1_pt = x0_pt + (0.5 * w_table_mm * MM_TO_PT)
x_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT)
s.line_stroke(x_div1_pt, y_row1_pt, x_div1_pt, y1_pt)
s.line_stroke(x_div2_pt, y_row1_pt, x_div2_pt, y1_pt)
x_r2_div1_pt = x0_pt + (0.4 * w_table_mm * MM_TO_PT)
x_r2_div2_pt = x0_pt + (0.8 * w_table_mm * MM_TO_PT)
s.line_stroke(x_r2_div1_pt, y0_pt, x_r2_div1_pt, y_row1_pt)
s.line_stroke(x_r2_div2_pt, y0_pt, x_r2_div2_pt, y_row1_pt)
# Extract field values with fallbacks
company_val = str(header_info.get("company", "") or "-")
part_name_val = str(header_info.get("part_name", "") or obj_name or "-")
dwg_val = str(header_info.get("drawing_number", "") or "-")
author_val = str(header_info.get("author", "") or "-")
rev_val = str(header_info.get("revision", "") or "A")
mat_val = str(header_info.get("material", "") or "-")
tol_val = str(header_info.get("tolerance", "") or "-")
date_val = str(header_info.get("date", "") or datetime.date.today().isoformat())
scale_val = str(header_info.get("scale", "") or "1:1")
cells = [
# Row 0 (Top)
("COMPANY", company_val, x0_pt, y_row2_pt, h_row_pt),
("DWG NO", dwg_val, x_div1_pt, y_row2_pt, h_row_pt),
("REV", rev_val, x_div2_pt, y_row2_pt, h_row_pt),
# Row 1 (Middle)
("PART NAME", part_name_val, x0_pt, y_row1_pt, h_row_pt),
("MATERIAL", mat_val, x_div1_pt, y_row1_pt, h_row_pt),
("TOLERANCE", tol_val, x_div2_pt, y_row1_pt, h_row_pt),
# Row 2 (Bottom)
("AUTHOR", author_val, x0_pt, y0_pt, h_row_pt),
("DATE", date_val, x_r2_div1_pt, y0_pt, h_row_pt),
("SCALE", scale_val, x_r2_div2_pt, y0_pt, h_row_pt),
]
eff_font = font_size_pt if font_size_pt is not None and font_size_pt > 0 else 10.0
lbl_font_pt = eff_font * 0.5
val_font_pt = eff_font * 0.75
label_color = (0.35, 0.35, 0.35)
for lbl_text, val_text, cx, cy, ch in cells:
# Small field label
lx = cx + (1.5 * MM_TO_PT)
ly = cy + ch - (lbl_font_pt * 1.1)
s.begin_text().font("F1", lbl_font_pt)
s.fill_color(*label_color)
s.text_position(lx, ly)
s.show(lbl_text)
s.end_text()
# Field value (bold)
vx = cx + (1.5 * MM_TO_PT)
vy = cy + (1.5 * MM_TO_PT)
s.begin_text().font("F1", val_font_pt)
s.fill_color(sr, sg, sb)
bold_w = max(0.15, val_font_pt * 0.035)
s.stroke_color(sr, sg, sb).line_width(bold_w).text_rendering_mode(2)
s.text_position(vx, vy)
s.show(val_text)
s.text_rendering_mode(0)
s.end_text()
def get_all_slicing_objects(mesh_data: Any) -> list[Any]:
from ...infra.blender.blender_mesh_capture import SlicingGroup, SlicingCollection, SlicingObject
objects = []
def process_item(item: Any):
if item is None:
return
elif isinstance(item, (list, tuple)):
for sub in item:
process_item(sub)
elif isinstance(item, SlicingGroup):
for coll in item.collections.values():
process_item(coll)
elif isinstance(item, SlicingCollection):
for so in item.objects:
process_item(so)
elif isinstance(item, SlicingObject):
objects.append(item)
process_item(mesh_data)
return objects
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_header: bool = False,
header_info: dict[str, Any] | None = None,
show_name: bool = False,
show_bbox_size: bool = False,
crease_angle: float = 30.0,
margin_mm: float = 10.0,
line_width_mm: float = 0.0,
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,
**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.
"""
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
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
# Ensure grid is symmetric around center
grid_min_x = min_x - pad_x
grid_max_x = max_x + pad_x
grid_min_y = min_y - pad_y
grid_max_y = max_y + pad_y
# Ensure grid aligns with minor_step grid lines
grid_min_x = math.floor(grid_min_x / minor_step) * minor_step
grid_max_x = math.ceil(grid_max_x / minor_step) * minor_step
grid_min_y = math.floor(grid_min_y / minor_step) * minor_step
grid_max_y = math.ceil(grid_max_y / minor_step) * minor_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))
s = PdfStream()
# 1. Solid background fill
br, bg, bb = bg_color
sr, sg, sb = stroke_color
s.fill_color(br, bg, bb)
s.fill_rect(0, 0, page_w_pt, page_h_pt)
# 2. Wireframe stroke color
s.stroke_color(sr, sg, sb)
# 3. Minor Grid Lines (25% Opacity)
if len(minor_lines_arr) > 0:
s.opacity("GS25").line_width(line_width_pt)
s.draw_line_batch(minor_lines_arr)
# 4. Main Grid Lines (50% Opacity)
if len(main_lines_arr) > 0:
s.opacity("GS50").line_width(line_width_pt)
s.draw_line_batch(main_lines_arr)
# 5. Object Lines (100% Opacity)
slicing_objs = get_all_slicing_objects(raw_geometry)
if slicing_objs:
for so in slicing_objs:
so_edge_list: list[NDArray[np.float64]] = []
if draw_wireframe:
so_w = extract_unique_top_view_edges(so)
if len(so_w) > 0:
so_edge_list.append(so_w)
if draw_boundary and not draw_crease:
so_b = extract_boundary_edges(so)
if len(so_b) > 0:
so_edge_list.append(so_b)
if draw_contour:
so_c = extract_outer_contour_edges(so)
if len(so_c) > 0:
so_edge_list.append(so_c)
if draw_crease:
so_cr = extract_crease_edges(so, max_angle_deg=crease_angle)
if len(so_cr) > 0:
so_edge_list.append(so_cr)
if so_edge_list:
so_combined = np.concatenate(so_edge_list, axis=0)
so_edges_to_draw = deduplicate_2d_edges(so_combined)
else:
so_edges_to_draw = np.empty((0, 2, 2), dtype=np.float64)
if len(so_edges_to_draw) > 0:
so_lw_mm = so.pdf_properties.line_width if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_width > 0.0) else line_width_mm
so_color = so.pdf_properties.line_color if (hasattr(so, "pdf_properties") and so.pdf_properties and so.pdf_properties.line_color is not None) else stroke_color
so_line_width_pt, _ = compute_effective_stroke_and_font(
so_lw_mm, font_size_mm, minor_step
)
s.opacity("GS100").line_width(so_line_width_pt).stroke_color(so_color[0], so_color[1], so_color[2]).round_caps()
p0_pt = to_pdf_pt(so_edges_to_draw[:, 0, :])
p1_pt = to_pdf_pt(so_edges_to_draw[:, 1, :])
obj_lines_arr = np.column_stack([p0_pt, p1_pt]).reshape(-1, 2, 2)
s.draw_line_batch(obj_lines_arr)
elif len(edges_to_draw) > 0:
s.opacity("GS100").line_width(line_width_pt).stroke_color(stroke_color[0], stroke_color[1], stroke_color[2]).round_caps()
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)
s.draw_line_batch(obj_lines_arr)
# 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
s.opacity("GS100").line_width(line_width_pt).round_caps()
s.stroke_color(b_r, b_g, b_b)
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)
s.draw_line_batch(bbox_lines_arr)
# 5.6. SA2 Automated Technical Measures Block (Holes & Concentric Bores)
if raw_geometry is not None and draw_sa2_holes:
render_sa2_measures_pdf(
s,
raw_geometry,
draw_sa2_holes=draw_sa2_holes,
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)
s.opacity("GS100").color(0, 0, 0)
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
s.line_width(line_width_pt).round_caps()
# 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
s.line_stroke(x_start_pt, y_bar_pt, x_arrow_tip_pt, y_bar_pt)
# Pointy arrowhead (+X) extending past last tick
s.polyline_stroke(
(x_arrow_base_pt, y_bar_pt + arrow_half_w_pt),
(x_arrow_tip_pt, y_bar_pt),
(x_arrow_base_pt, y_bar_pt - arrow_half_w_pt),
)
# Ticks directly overlapping vertical main grid lines on X-axis
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
s.line(xk_pt, y_bar_pt, xk_pt, y_bar_pt + tick_h_pt)
s.stroke()
# 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
s.line_stroke(x_start_pt, y_bar_pt, x_start_pt, y_arrow_tip_pt)
# Pointy arrowhead (+Y) extending past y_top_pt
s.polyline_stroke(
(x_start_pt - arrow_half_w_pt, y_arrow_base_pt),
(x_start_pt, y_arrow_tip_pt),
(x_start_pt + arrow_half_w_pt, y_arrow_base_pt),
)
# 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label
s.fill_color(0, 0, 0)
s.begin_text().font("F1", font_size_pt)
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 + leg_sizing.label_margin_h_pt
s.text_position(x_text_pt, y_text_pt).show(tick_str)
# X axis label next to right arrowhead
x_x_label_pt = x_arrow_tip_pt + leg_sizing.label_margin_h_pt
y_x_label_pt = y_bar_pt - (font_size_pt * 0.35)
s.text_position(x_x_label_pt, y_x_label_pt).show("X")
# 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 + leg_sizing.label_margin_h_pt
s.text_position(x_y_label_pt, y_y_label_pt).show("Y")
s.end_text()
# 7. Object Names, Bounding Box Sizes, and Settings Attributes (to the right of the rightmost vertex)
settings_items = extract_settings_items(settings)
slicing_objs_list = get_all_slicing_objects(raw_geometry)
has_any_obj_show_name = any(hasattr(o, "pdf_properties") and o.pdf_properties and o.pdf_properties.show_name for o in slicing_objs_list)
if (show_name or has_any_obj_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
s.opacity("GS100")
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] + leg_sizing.label_margin_w_pt
y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35)
lines_to_draw: list[tuple[str, tuple[float, float, float], bool]] = []
so_show_name = (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.show_name) or show_name
so_color = (
so_item.pdf_properties.line_color
if (hasattr(so_item, "pdf_properties") and so_item.pdf_properties and so_item.pdf_properties.line_color is not None)
else stroke_color
)
if so_show_name:
lines_to_draw.append((str(obj_name), so_color, True))
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, False))
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, False))
if lines_to_draw:
for line_idx, (line_str, col, is_bold) in enumerate(lines_to_draw):
cr, cg, cb = col
curr_y_pt = y_text_pt - (line_idx * line_spacing_pt)
s.begin_text().font("F1", eff_font_size_pt)
s.fill_color(cr, cg, cb)
if is_bold:
bold_stroke_w = max(0.2, eff_font_size_pt * 0.035)
s.stroke_color(cr, cg, cb).line_width(bold_stroke_w).text_rendering_mode(2)
else:
s.text_rendering_mode(0)
s.text_position(x_text_pt, curr_y_pt)
s.show(line_str)
if is_bold:
s.text_rendering_mode(0)
s.end_text()
# 8. Technical Drawing Header Block (Bottom Right Table)
if draw_header and header_info:
primary_obj_name = ""
if slicing_objs_list:
primary_obj_name = str(getattr(slicing_objs_list[0], "name", ""))
elif raw_geometry is not None and hasattr(raw_geometry, "name"):
primary_obj_name = str(getattr(raw_geometry, "name", ""))
render_technical_header_table(
s=s,
page_w_pt=page_w_pt,
page_h_pt=page_h_pt,
total_margin_mm=total_margin_mm,
header_info=header_info,
stroke_color=stroke_color,
bg_color=bg_color,
font_size_pt=font_size_pt,
line_width_pt=line_width_pt,
obj_name=primary_obj_name,
)
stream_bytes = s.encode()
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")