From 7e43f42c1ca0e334565c7194aab794c003cc07e0 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Thu, 30 Jul 2026 12:47:36 +0300 Subject: [PATCH] up --- core/pdf_exporter/sa2_measures.py | 41 ++++++++++++--- core/pdf_exporter/writer.py | 85 +++++++++++++++++++++---------- tests/test_pdf_exporter.py | 8 +-- 3 files changed, 95 insertions(+), 39 deletions(-) diff --git a/core/pdf_exporter/sa2_measures.py b/core/pdf_exporter/sa2_measures.py index cb6505e..b871df3 100644 --- a/core/pdf_exporter/sa2_measures.py +++ b/core/pdf_exporter/sa2_measures.py @@ -12,6 +12,31 @@ from .geometry import extract_lineart_edges, extract_unique_top_view_edges, dedu MM_TO_PT = 2.834645669291339 +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 circumcircle_3pts(p1: NDArray, p2: NDArray, p3: NDArray) -> tuple[tuple[float, float] | None, float | None]: """Calculate center and radius of circumcircle passing through 3 2D points.""" ax, ay = float(p1[0]), float(p1[1]) @@ -349,13 +374,13 @@ def render_sa2_measures_pdf( x_w_text = (x_w1 + x_w2) / 2.0 - (len(w_str) * font_size_pt * 0.28) y_w_text = y_w - font_size_pt * 1.1 stream_cmds.append(f"1 0 0 1 {x_w_text:.2f} {y_w_text:.2f} Tm") - stream_cmds.append(f"({w_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(w_str)}) Tj") h_str = f"{h_obj_mm/1000:g}m" if main_step >= 1000.0 else f"{h_obj_mm:g}mm" x_h_text = x_h - (len(h_str) * font_size_pt * 0.55) - (2.0 * MM_TO_PT) y_h_text = (y_h1 + y_h2) / 2.0 - (font_size_pt * 0.35) stream_cmds.append(f"1 0 0 1 {x_h_text:.2f} {y_h_text:.2f} Tm") - stream_cmds.append(f"({h_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(h_str)}) Tj") stream_cmds.append("ET") @@ -392,11 +417,11 @@ def render_sa2_measures_pdf( ) dia_val = round(dia_mm, 1) - dia_str = f"DIA {dia_val:g}mm" if main_step < 1000.0 else f"DIA {dia_val/1000:g}m" + dia_str = f"Ø{dia_val:g}mm" if main_step < 1000.0 else f"Ø{dia_val/1000:g}m" stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf") stream_cmds.append(f"1 0 0 1 {x_s1 + 2.0:.2f} {y_s1 + 2.0:.2f} Tm") - stream_cmds.append(f"({dia_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(dia_str)}) Tj") stream_cmds.append("ET") # 2. ISO 129-1 Arc Cutouts & Corner Fillets (R - Amber Orange) @@ -433,7 +458,7 @@ def render_sa2_measures_pdf( stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf") stream_cmds.append(f"1 0 0 1 {xs1 + 2.0:.2f} {ys1 + 2.0:.2f} Tm") - stream_cmds.append(f"({r_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(r_str)}) Tj") stream_cmds.append("ET") # 3. ISO 129-1 Bevels & Chamfers (C / 45° - Violet Purple) @@ -469,7 +494,7 @@ def render_sa2_measures_pdf( stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") stream_cmds.append(f"1 0 0 1 {x_cs1 + 2.0:.2f} {y_cs1 + 2.0:.2f} Tm") - stream_cmds.append(f"({c_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(c_str)}) Tj") stream_cmds.append("ET") # 4. Hole Center Pitch Distances (Teal Green) @@ -494,7 +519,7 @@ def render_sa2_measures_pdf( stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") stream_cmds.append(f"1 0 0 1 {(x1+x2)/2.0:.2f} {(y1+y2)/2.0 + 3.0:.2f} Tm") - stream_cmds.append(f"({pitch_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(pitch_str)}) Tj") stream_cmds.append("ET") # 5. Interior Step / Notch Feature Segment Lengths (Deep Purple) @@ -514,5 +539,5 @@ def render_sa2_measures_pdf( stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf") stream_cmds.append(f"1 0 0 1 {(xs1+xs2)/2.0:.2f} {(ys1+ys2)/2.0 + 2.0:.2f} Tm") - stream_cmds.append(f"({step_str}) Tj") + stream_cmds.append(f"({escape_pdf_text(step_str)}) Tj") stream_cmds.append("ET") diff --git a/core/pdf_exporter/writer.py b/core/pdf_exporter/writer.py index fb2220b..32e9e36 100644 --- a/core/pdf_exporter/writer.py +++ b/core/pdf_exporter/writer.py @@ -26,6 +26,31 @@ from .sa2_measures import render_sa2_measures_pdf MM_TO_PT = 2.834645669291339 +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. @@ -296,6 +321,8 @@ def export_mesh_to_pdf( # 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") 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)) @@ -361,7 +388,7 @@ def export_mesh_to_pdf( ) # 6c. Text labels for measure ticks (skip k=0: NO text label at zero), X label, and Y label - stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") + stream_cmds.append("0.000 0.000 0.000 rg") stream_cmds.append("BT") stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") @@ -380,10 +407,11 @@ def export_mesh_to_pdf( unit = " mm" if k == num_main_steps else "" tick_str = f"{val_mm:g}{unit}" - x_text_pt = xk_pt - (len(tick_str) * font_size_pt * 0.28) - y_text_pt = y_bar_pt + tick_h_pt + (2.5 * MM_TO_PT) + 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"({tick_str}) Tj") + 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) @@ -404,22 +432,19 @@ def export_mesh_to_pdf( 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: - font_size_pt = max(8.0, line_width_pt * 4.0) - line_spacing_pt = font_size_pt * 1.2 + 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") - stream_cmds.append(f"{sr:.3f} {sg:.3f} {sb:.3f} rg") - stream_cmds.append("BT") - stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") 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] - (font_size_pt * 0.35) + y_text_pt = p_pt[0, 1] - (eff_font_size_pt * 0.35) - lines_to_draw: list[str] = [] + lines_to_draw: list[tuple[str, tuple[float, float, float]]] = [] if show_name: - lines_to_draw.append(str(obj_name)) + lines_to_draw.append((str(obj_name), stroke_color)) if show_bbox_size: if main_step >= 1000.0: @@ -428,7 +453,7 @@ def export_mesh_to_pdf( 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) + 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) @@ -439,15 +464,21 @@ def export_mesh_to_pdf( val_str = str(attr_val) if suffix: val_str = f"{val_str}{suffix}" - lines_to_draw.append(f"{key_name}: {val_str}") + lines_to_draw.append((f"{key_name}: {val_str}", stroke_color)) - for line_idx, line_str in enumerate(lines_to_draw): - clean_str = line_str.replace("(", "\\(").replace(")", "\\)") - 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") + if lines_to_draw: + stream_cmds.append("BT") + stream_cmds.append(f"/F1 {eff_font_size_pt:.1f} Tf") - stream_cmds.append("ET") + 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") @@ -470,10 +501,10 @@ def export_mesh_to_pdf( 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 Open Gorton Typeface - font_file_path = Path(__file__).parent / "fonts" / "OpenGorton-Regular.otf" + # 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" / "OpenGorton-Regular.otf" + font_file_path = Path(__file__).parents[2] / "fonts" / "LeagueMono-Regular.otf" font_bytes: bytes | None = None if font_file_path.exists(): @@ -487,22 +518,22 @@ def export_mesh_to_pdf( widths_str = " ".join(["600"] * 224) objects.append( f"8 0 obj\n" - f"<< /Type /Font /Subtype /Type1 /BaseFont /OpenGorton-Regular " + 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 /OpenGorton-Regular /Flags 32 " - b"/FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 " + 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 /Helvetica >>\nendobj\n") + 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" diff --git a/tests/test_pdf_exporter.py b/tests/test_pdf_exporter.py index dd4fc84..dd6200e 100644 --- a/tests/test_pdf_exporter.py +++ b/tests/test_pdf_exporter.py @@ -678,9 +678,9 @@ class TestPdfExporter(unittest.TestCase): self.assertIn("/F1 14.5 Tf", content) - def test_export_pdf_open_gorton_font_embedded(self): + def test_export_pdf_league_mono_font_embedded(self): so = SlicingObject() - so.name = "GortonTestPart" + so.name = "LeagueMonoTestPart" so.mesh = np.array([ [[0, 0, 0], [10, 0, 0], [10, 10, 0]], [[0, 0, 0], [10, 10, 0], [0, 10, 0]] @@ -689,7 +689,7 @@ class TestPdfExporter(unittest.TestCase): edges_2d = extract_unique_top_view_edges(so) with tempfile.TemporaryDirectory() as tmpdir: - pdf_path = os.path.join(tmpdir, "test_open_gorton.pdf") + pdf_path = os.path.join(tmpdir, "test_league_mono.pdf") export_mesh_to_pdf( edges_2d, pdf_path, @@ -700,7 +700,7 @@ class TestPdfExporter(unittest.TestCase): with open(pdf_path, "rb") as f: content = f.read().decode("utf-8", errors="ignore") - self.assertIn("OpenGorton-Regular", content) + self.assertIn("LeagueMono-Regular", content) self.assertIn("/Subtype /OpenType", content)