This commit is contained in:
Nicolas Predella 2026-07-30 12:47:36 +03:00
parent 71301f85f7
commit 7e43f42c1c
3 changed files with 95 additions and 39 deletions

View file

@ -12,6 +12,31 @@ from .geometry import extract_lineart_edges, extract_unique_top_view_edges, dedu
MM_TO_PT = 2.834645669291339 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]: 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.""" """Calculate center and radius of circumcircle passing through 3 2D points."""
ax, ay = float(p1[0]), float(p1[1]) 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) 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 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"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" 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) 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) 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"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") stream_cmds.append("ET")
@ -392,11 +417,11 @@ def render_sa2_measures_pdf(
) )
dia_val = round(dia_mm, 1) 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("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf") 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"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") stream_cmds.append("ET")
# 2. ISO 129-1 Arc Cutouts & Corner Fillets (R - Amber Orange) # 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("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.9:.1f} Tf") 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"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") stream_cmds.append("ET")
# 3. ISO 129-1 Bevels & Chamfers (C / 45° - Violet Purple) # 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("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") 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"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") stream_cmds.append("ET")
# 4. Hole Center Pitch Distances (Teal Green) # 4. Hole Center Pitch Distances (Teal Green)
@ -494,7 +519,7 @@ def render_sa2_measures_pdf(
stream_cmds.append("BT") stream_cmds.append("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.85:.1f} Tf") 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"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") stream_cmds.append("ET")
# 5. Interior Step / Notch Feature Segment Lengths (Deep Purple) # 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("BT")
stream_cmds.append(f"/F1 {font_size_pt * 0.8:.1f} Tf") 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"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") stream_cmds.append("ET")

View file

@ -26,6 +26,31 @@ from .sa2_measures import render_sa2_measures_pdf
MM_TO_PT = 2.834645669291339 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]: 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. 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) # 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("/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_start_mm = grid_min_x
x_grid_end_mm = min(grid_max_x, grid_min_x + 10.0 * main_step) 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)) 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 # 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("BT")
stream_cmds.append(f"/F1 {font_size_pt:.1f} Tf") 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 "" unit = " mm" if k == num_main_steps else ""
tick_str = f"{val_mm:g}{unit}" tick_str = f"{val_mm:g}{unit}"
x_text_pt = xk_pt - (len(tick_str) * font_size_pt * 0.28) tw_pt = len(tick_str) * font_size_pt * 0.60
y_text_pt = y_bar_pt + tick_h_pt + (2.5 * MM_TO_PT) 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"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 axis label next to right arrowhead
x_x_label_pt = x_arrow_tip_pt + (2.0 * MM_TO_PT) 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: if (show_name or show_bbox_size or settings_items) and raw_geometry is not None:
label_infos = extract_object_label_info(raw_geometry) label_infos = extract_object_label_info(raw_geometry)
if label_infos: if label_infos:
font_size_pt = max(8.0, line_width_pt * 4.0) 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 = font_size_pt * 1.2 line_spacing_pt = eff_font_size_pt * 1.25
stream_cmds.append("/GS100 gs") 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: 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]])) 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) 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: if show_name:
lines_to_draw.append(str(obj_name)) lines_to_draw.append((str(obj_name), stroke_color))
if show_bbox_size: if show_bbox_size:
if main_step >= 1000.0: 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)" size_str = f"(X: {w_val:g}m Y: {h_val:g}m)"
else: else:
size_str = f"(X: {w_obj_mm:g}mm Y: {h_obj_mm:g}mm)" 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: for key_name, suffix in settings_items:
attr_val = get_first_attribute_value(so_item, key_name) attr_val = get_first_attribute_value(so_item, key_name)
@ -439,10 +464,16 @@ def export_mesh_to_pdf(
val_str = str(attr_val) val_str = str(attr_val)
if suffix: if suffix:
val_str = f"{val_str}{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): if lines_to_draw:
clean_str = line_str.replace("(", "\\(").replace(")", "\\)") 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) 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"1 0 0 1 {x_text_pt:.2f} {curr_y_pt:.2f} Tm")
stream_cmds.append(f"({clean_str}) Tj") stream_cmds.append(f"({clean_str}) Tj")
@ -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"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") objects.append(b"7 0 obj\n<< /Type /ExtGState /CA 1.0 /ca 1.0 >>\nendobj\n")
# Embed Open Gorton Typeface # Embed League Mono Typeface
font_file_path = Path(__file__).parent / "fonts" / "OpenGorton-Regular.otf" font_file_path = Path(__file__).parent / "fonts" / "LeagueMono-Regular.otf"
if not font_file_path.exists(): 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 font_bytes: bytes | None = None
if font_file_path.exists(): if font_file_path.exists():
@ -487,22 +518,22 @@ def export_mesh_to_pdf(
widths_str = " ".join(["600"] * 224) widths_str = " ".join(["600"] * 224)
objects.append( objects.append(
f"8 0 obj\n" 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"/FirstChar 32 /LastChar 255 "
f"/Widths [ {widths_str} ] " f"/Widths [ {widths_str} ] "
f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8") f"/FontDescriptor 9 0 R /Encoding /WinAnsiEncoding >>\nendobj\n".encode("utf-8")
) )
objects.append( objects.append(
b"9 0 obj\n" b"9 0 obj\n"
b"<< /Type /FontDescriptor /FontName /OpenGorton-Regular /Flags 32 " b"<< /Type /FontDescriptor /FontName /LeagueMono-Regular /Flags 33 "
b"/FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 " b"/FontBBox [-200 -200 1200 1000] /ItalicAngle 0 /Ascent 1000 /Descent -200 "
b"/CapHeight 700 /StemV 80 /FontFile3 10 0 R >>\nendobj\n" 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_header = f"10 0 obj\n<< /Length {len(font_bytes)} /Subtype /OpenType >>\nstream\n".encode("utf-8")
font_stream_footer = b"\nendstream\nendobj\n" font_stream_footer = b"\nendstream\nendobj\n"
objects.append(font_stream_header + font_bytes + font_stream_footer) objects.append(font_stream_header + font_bytes + font_stream_footer)
else: 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" header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"

View file

@ -678,9 +678,9 @@ class TestPdfExporter(unittest.TestCase):
self.assertIn("/F1 14.5 Tf", content) 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 = SlicingObject()
so.name = "GortonTestPart" so.name = "LeagueMonoTestPart"
so.mesh = np.array([ so.mesh = np.array([
[[0, 0, 0], [10, 0, 0], [10, 10, 0]], [[0, 0, 0], [10, 0, 0], [10, 10, 0]],
[[0, 0, 0], [10, 10, 0], [0, 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) edges_2d = extract_unique_top_view_edges(so)
with tempfile.TemporaryDirectory() as tmpdir: 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( export_mesh_to_pdf(
edges_2d, edges_2d,
pdf_path, pdf_path,
@ -700,7 +700,7 @@ class TestPdfExporter(unittest.TestCase):
with open(pdf_path, "rb") as f: with open(pdf_path, "rb") as f:
content = f.read().decode("utf-8", errors="ignore") content = f.read().decode("utf-8", errors="ignore")
self.assertIn("OpenGorton-Regular", content) self.assertIn("LeagueMono-Regular", content)
self.assertIn("/Subtype /OpenType", content) self.assertIn("/Subtype /OpenType", content)