diff --git a/README.md b/README.md index 98f6ebe..99a7f9b 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Prebuilt single-file binaries (PyInstaller): ![License](https://img.shields.io/badge/License-MIT-yellow) ## Overview -Text2Gcode converts a single line of text into basic G-code moves (G0/G1) suitable for pen plotters, light engraving, or CNC simulation. It offers font selection, automatic size fitting, adjustable line width preview, and direct export. +Text2Gcode converts a single line of text into basic G-code moves (G0/G1/G5) suitable for pen plotters, light engraving, or CNC simulation. It offers font selection, automatic size fitting, adjustable line width preview, and direct export. ## Features - System font selection (Qt `QFontComboBox`) @@ -40,7 +40,7 @@ Text2Gcode converts a single line of text into basic G-code moves (G0/G1) suitab ## How It Works 1. Text is converted to a `QPainterPath` 2. Path geometry is scaled (default: `0.1` units → mm) -3. Each path element is emitted as rapid (`G0`) or linear (`G1`) move +3. Each path element is emitted as rapid (`G0`), linear (`G1`) or cubic spline (`G5`) move 4. Pen up/down simulated via Z moves (`safe_z` / `cut_z`) 5. Output ends with `M2` @@ -109,4 +109,4 @@ Copyright © 2025. 📡 vy 73 de OE7SET --- -Enjoy precise text-to-gcode conversion with a minimal, transparent codebase. \ No newline at end of file +Enjoy precise text-to-gcode conversion with a minimal, transparent codebase. diff --git a/src/helper.py b/src/helper.py new file mode 100644 index 0000000..c3cdad9 --- /dev/null +++ b/src/helper.py @@ -0,0 +1,99 @@ +import sys +import os +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QFontMetrics, QPainterPath + +# Helper function: Convert text to path +def text_to_path(text, font_family="Arial", font_size=50): + font = QFont(font_family, font_size) + font_metrics = QFontMetrics(font) + line_spacing = font_metrics.lineSpacing(); + #print(f"Spacing {font_metrics.lineSpacing()}", file=sys.stderr) + path = QPainterPath() + text_split=text.splitlines() + n_lines=len(text_split); + for i in range(n_lines): + #print(f"Text {i} {text_nl[i]}", file=sys.stderr) + path.addText(0, (i-n_lines+1)*line_spacing, font, text_split[i]) + return path + +def elem2xy(elem: QPainterPath.Element, scale=0.1, x_offset=0.0, y_offset=0.0): + #print(elem.type, file=sys.stderr) + #print(elem.x, file=sys.stderr) + #print(elem.y, file=sys.stderr) + x = elem.x * scale + x_offset + y = -elem.y * scale + y_offset # Invert Y-axis for CNC + return [x, y] + +# Helper function: Convert path to G-Code +def path_to_gcode(path: QPainterPath, scale=0.1, safe_z=5.0, cut_z=0.0, feedrate=500, + x_offset=0.0, y_offset=0.0, z_offset=0.0, use_g5=True, g_prefix=[""], g_postfix=[""]): + gcode = g_prefix + + if path.elementCount() == 0: + return "\n".join(gcode) + + pen_down = True #Stat with pen_down so first move is up to save position + + i = 0 + while i < path.elementCount(): + elem = path.elementAt(i) + + if elem.isMoveTo(): + if pen_down: + gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up + pen_down = False + [x0, y0] = elem2xy(elem, scale, x_offset, y_offset) + gcode.append(f"G0 X{x0:.2f} Y{y0:.2f}") # Position + + elif elem.isLineTo(): # LineTo + assert(i != 0) #First should be move to + if not pen_down: + gcode.append(f"G1 Z{cut_z + z_offset:.2f} F{feedrate}") # Pen down + pen_down = True + [x0, y0] = elem2xy(elem, scale, x_offset, y_offset) + gcode.append(f"G1 X{x0:.2f} Y{y0:.2f} F{feedrate}") + + elif elem.isCurveTo(): # CurveTo + assert(i != 0) #First should be move to + if not pen_down: + gcode.append(f"G1 Z{cut_z + z_offset:.2f} F{feedrate}") # Pen down + pen_down = True + + # See: https://doc.qt.io/qt-6/qpainterpath.html#cubicTo + # and https://linuxcnc.org/docs/html/gcode/g-code.html#gcode:g5 + + #x0, y0: Start is given by last element + [x1, y1] = elem2xy(elem, scale, x_offset, y_offset) #c1 + + i=i+1; + elem = path.elementAt(i) + assert(elem.type == QPainterPath.CurveToDataElement) #CurveTo is always followed by two CurveToDataElement + [x2, y2] = elem2xy(elem, scale, x_offset, y_offset) #c2 + + i=i+1; + elem = path.elementAt(i) + assert(elem.type == QPainterPath.CurveToDataElement) #CurveTo is always followed by two CurveToDataElement + [x3, y3] = elem2xy(elem, scale, x_offset, y_offset) #end + + if use_g5: + gcode.append(f"G5 I{x1-x0:.2f} J{y1-y0:.2f} P{x2-x3:.2f} Q{y2-y3:.2f} X{x3:.2f} Y{y3:.2f} F{feedrate}") + else: + #Just move trough control points, looks better, even if it is basicaly wrong + #ToDo: Do interpolation somehow? + gcode.append(f"G1 X{x1:.2f} Y{y1:.2f} F{feedrate}") + gcode.append(f"G1 X{x2:.2f} Y{y2:.2f} F{feedrate}") + gcode.append(f"G1 X{x3:.2f} Y{y3:.2f} F{feedrate}") + + #End is next start for spline + x0=x3 + y0=y3 + + i=i+1 + + if pen_down: + gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up at the end + + gcode = gcode + g_postfix + + return "\n".join(gcode) diff --git a/src/main.py b/src/main.py index ff5c1d9..452e937 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ import sys import os +from helper import text_to_path, path_to_gcode from PySide6.QtCore import QSize, Qt, QTimer from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QIcon from PySide6.QtWidgets import ( @@ -23,54 +24,6 @@ QGridLayout ) - -# Helper function: Convert text to path -def text_to_path(text, font_family="Arial", font_size=50): - font = QFont(font_family, font_size) - path = QPainterPath() - path.addText(0, 0, font, text) - return path - - -# Helper function: Convert path to G-Code -def path_to_gcode(path: QPainterPath, scale=0.1, safe_z=5.0, cut_z=0.0, feedrate=500, - x_offset=0.0, y_offset=0.0, z_offset=0.0): - gcode = [ - "G21 ; mm mode", - "G90 ; absolute positioning" - ] - - if path.elementCount() == 0: - return "\n".join(gcode) - - pen_down = False - - for i in range(path.elementCount()): - elem = path.elementAt(i) - - x = elem.x * scale + x_offset - y = -elem.y * scale + y_offset # Invert Y-axis for CNC - - if elem.type == QPainterPath.ElementType.MoveToElement: - if pen_down: - gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up - pen_down = False - gcode.append(f"G0 X{x:.2f} Y{y:.2f}") # Position - - else: # LineTo or CurveTo - if not pen_down: - gcode.append(f"G1 Z{cut_z + z_offset:.2f} F{feedrate}") # Pen down - pen_down = True - gcode.append(f"G1 X{x:.2f} Y{y:.2f} F{feedrate}") - - if pen_down: - gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up at the end - - gcode.append("M2 ; Program end") - return "\n".join(gcode) - - - class PreviewWidget(QWidget): def __init__(self): super().__init__() @@ -162,7 +115,7 @@ def __init__(self): # Text input field text_label = QLabel("Enter Text:") - self.text_input = QLineEdit() + self.text_input = QTextEdit() self.text_input.setPlaceholderText("Type your text here...") input_layout.addWidget(text_label) input_layout.addWidget(self.text_input) @@ -340,7 +293,7 @@ def update_line_width(self): self.preview.set_line_width(self.line_width_spin.value()) def generate_gcode(self): - text = self.text_input.text() + text = self.text_input.toPlainText() if not text.strip(): self.status_bar.showMessage("Please enter some text", 3000) return @@ -396,8 +349,21 @@ def generate_gcode(self): height_mm = bounds.height() * scale self.dimensions_label.setText(f"Dimensions: {width_mm:.2f} x {height_mm:.2f} mm") + g_prefix = [ + # Information + "; Text = " + "\\n".join(text.splitlines()), + f"; Dimensions: {width_mm:.2f} x {height_mm:.2f} mm", + # Prefix + "G21 ; mm mode", + "G90 ; absolute positioning" + ] + g_postfix = [ + "M2 ; Program end" + ] + gcode = path_to_gcode(path, scale=scale, - x_offset=x_offset, y_offset=y_offset, z_offset=z_offset, feedrate=feedrate) + x_offset=x_offset, y_offset=y_offset, z_offset=z_offset, feedrate=feedrate, + g_prefix=g_prefix, g_postfix=g_postfix) self.gcode_preview.setPlainText(gcode) self.status_bar.showMessage("G-code generated successfully", 3000) @@ -416,7 +382,7 @@ def save_gcode(self): return # Default name from LineEdit text - default_name = self.text_input.text().strip() + default_name = self.text_input.toPlainText().strip().replace("\n", " ") if not default_name: default_name = "gcode" diff --git a/src/main_cli.py b/src/main_cli.py new file mode 100644 index 0000000..52d9c35 --- /dev/null +++ b/src/main_cli.py @@ -0,0 +1,57 @@ +import sys +import os +from PySide6.QtWidgets import ( + QApplication +) + +from helper import text_to_path, path_to_gcode + +if __name__ == "__main__": + app = QApplication(sys.argv) + + if len(sys.argv) != 4 and len(sys.argv) != 10: + print("Usage: main_cli.py font fontsize text [x y z feed gcode_prefix gcode_postfix]", file=sys.stderr) + sys.exit(-1) + + font_family = sys.argv[1] + font_size = float(sys.argv[2]) + scale = 0.1 # Same scale as in path_to_gcode + + text = sys.argv[3] + + # Get offset and feedrate values + if len(sys.argv) == 10: + x_offset = float(sys.argv[4]) + y_offset = float(sys.argv[5]) + z_offset = float(sys.argv[6]) + feedrate = float(sys.argv[7]) + g_prefix = sys.argv[8].splitlines() + g_postfix = sys.argv[9].splitlines() + else: + x_offset = 0 + y_offset = 0 + z_offset = 0 + feedrate = 500 + g_prefix = [ + "G21 ; mm mode", + "G90 ; absolute positioning" + ] + g_postfix = [ + "M2 ; Program end" + ] + + path = text_to_path(text, font_family=font_family, font_size=font_size) + + # Calculate dimensions + bounds = path.boundingRect() + width_mm = bounds.width() * scale + height_mm = bounds.height() * scale + + # Add information + g_prefix = ["; Text = " + "\\n".join(text.splitlines()), f"; Dimensions: {width_mm:.2f} x {height_mm:.2f} mm"] + g_prefix; + + gcode = path_to_gcode(path, scale=scale, + x_offset=x_offset, y_offset=y_offset, z_offset=z_offset, feedrate=feedrate, + g_prefix=g_prefix, g_postfix=g_postfix) + + print(gcode)