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