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