From 504a689c02ae5b6d4f5561f2bc536f3d318e94c8 Mon Sep 17 00:00:00 2001 From: Hannes Diethelm Date: Sun, 22 Feb 2026 22:55:15 +0100 Subject: [PATCH 1/5] Add G5 spline for nicer text and CLI app G5 See: https://linuxcnc.org/docs/html/gcode/g-code.html#gcode:g5 --- src/autogen.py | 36 ++++++++++++++++++++ src/helper.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.py | 49 +-------------------------- 3 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/autogen.py create mode 100644 src/helper.py diff --git a/src/autogen.py b/src/autogen.py new file mode 100644 index 0000000..c9fff5f --- /dev/null +++ b/src/autogen.py @@ -0,0 +1,36 @@ +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) + text = sys.argv[1] + font_family = "Noteworthy" + font_size = 100 + scale = 0.1 # Same scale as in path_to_gcode + + # Get offset values + x_offset = 0 + y_offset = 0 + z_offset = 0 + + # Get feedrate value + feedrate = 500 + + 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 + + gcode = path_to_gcode(path, scale=scale, + x_offset=x_offset, y_offset=y_offset, z_offset=z_offset, feedrate=feedrate) + + print("; Text = " + sys.argv[1]) + print(f"; Dimensions: {width_mm:.2f} x {height_mm:.2f} mm") + print(gcode) diff --git a/src/helper.py b/src/helper.py new file mode 100644 index 0000000..4beb005 --- /dev/null +++ b/src/helper.py @@ -0,0 +1,89 @@ +import sys +import os +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QPainterPath + +# 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 + +def elem2xy(elem: QPainterPath.Element, scale=0.1, x_offset=0.0, y_offset=0.0): + #print(elem.type) + #print(elem.x) + #print(elem.y) + 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): + gcode = [ + "G21 ; mm mode", + "G90 ; absolute positioning" + ] + + if path.elementCount() == 0: + return "\n".join(gcode) + + pen_down = False + + i = 0 + while i < path.elementCount(): + elem = path.elementAt(i) + + [x, y] = elem2xy(elem, scale, x_offset, y_offset) + if elem.isMoveTo(): + if pen_down: + gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up + #print(f"G0 Z{safe_z + z_offset:.2f}") + pen_down = False + gcode.append(f"G0 X{x:.2f} Y{y:.2f}") # Position + + #Next start for spline + x0=x + y0=y + + elif elem.isLineTo(): # LineTo + 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}") + + #Next start for spline + x0=x + y0=y + + elif elem.isCurveTo(): # CurveTo + if not pen_down: + gcode.append(f"G1 Z{cut_z + z_offset:.2f} F{feedrate}") # Pen down + pen_down = True + + [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 + + 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}") + + #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.append("M2 ; Program end") + return "\n".join(gcode) diff --git a/src/main.py b/src/main.py index ff5c1d9..5bde308 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__() From cf92d8064ee6ba73ca2c5db2ec8d2fece90c9ca3 Mon Sep 17 00:00:00 2001 From: Hannes Diethelm Date: Mon, 23 Feb 2026 23:45:43 +0100 Subject: [PATCH 2/5] Nicer code / main_cli --- src/helper.py | 26 ++++++++++++-------------- src/{autogen.py => main_cli.py} | 32 +++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 25 deletions(-) rename src/{autogen.py => main_cli.py} (55%) diff --git a/src/helper.py b/src/helper.py index 4beb005..297895a 100644 --- a/src/helper.py +++ b/src/helper.py @@ -29,39 +29,37 @@ def path_to_gcode(path: QPainterPath, scale=0.1, safe_z=5.0, cut_z=0.0, feedrate if path.elementCount() == 0: return "\n".join(gcode) - pen_down = False + 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) - - [x, y] = elem2xy(elem, scale, x_offset, y_offset) + if elem.isMoveTo(): if pen_down: gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up - #print(f"G0 Z{safe_z + z_offset:.2f}") pen_down = False - gcode.append(f"G0 X{x:.2f} Y{y:.2f}") # Position - - #Next start for spline - x0=x - y0=y + [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 - gcode.append(f"G1 X{x:.2f} Y{y:.2f} F{feedrate}") - - #Next start for spline - x0=x - y0=y + [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; diff --git a/src/autogen.py b/src/main_cli.py similarity index 55% rename from src/autogen.py rename to src/main_cli.py index c9fff5f..e63de5d 100644 --- a/src/autogen.py +++ b/src/main_cli.py @@ -8,18 +8,28 @@ if __name__ == "__main__": app = QApplication(sys.argv) - text = sys.argv[1] - font_family = "Noteworthy" - font_size = 100 + + if len(sys.argv) != 4 and len(sys.argv) != 8: + print("Usage: main_cli.py font fontsize text [x y z feed]", 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 - - # Get offset values - x_offset = 0 - y_offset = 0 - z_offset = 0 - - # Get feedrate value - feedrate = 500 + + text = sys.argv[3] + + # Get offset and feedrate values + if len(sys.argv) == 8: + x_offset = float(sys.argv[4]) + y_offset = float(sys.argv[5]) + z_offset = float(sys.argv[6]) + feedrate = float(sys.argv[7]) + else: + x_offset = 0 + y_offset = 0 + z_offset = 0 + feedrate = 500 path = text_to_path(text, font_family=font_family, font_size=font_size) From bcc825069f18053194d2c563c3c5f31b9d027b16 Mon Sep 17 00:00:00 2001 From: Hannes Diethelm Date: Mon, 23 Feb 2026 23:50:01 +0100 Subject: [PATCH 3/5] Option to disable G5 --- src/helper.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/helper.py b/src/helper.py index 297895a..078306b 100644 --- a/src/helper.py +++ b/src/helper.py @@ -20,7 +20,7 @@ def elem2xy(elem: QPainterPath.Element, scale=0.1, x_offset=0.0, y_offset=0.0): # 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): + x_offset=0.0, y_offset=0.0, z_offset=0.0, use_g5=True): gcode = [ "G21 ; mm mode", "G90 ; absolute positioning" @@ -72,7 +72,14 @@ def path_to_gcode(path: QPainterPath, scale=0.1, safe_z=5.0, cut_z=0.0, feedrate assert(elem.type == QPainterPath.CurveToDataElement) #CurveTo is always followed by two CurveToDataElement [x3, y3] = elem2xy(elem, scale, x_offset, y_offset) #end - 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}") + 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 From 217524bb2628ba82cf36b62e08e53868a0f8eebf Mon Sep 17 00:00:00 2001 From: Hannes Diethelm Date: Tue, 24 Feb 2026 00:29:09 +0100 Subject: [PATCH 4/5] Multiline support --- src/helper.py | 17 ++++++++++++----- src/main.py | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/helper.py b/src/helper.py index 078306b..f8facb5 100644 --- a/src/helper.py +++ b/src/helper.py @@ -1,19 +1,26 @@ import sys import os from PySide6.QtCore import Qt -from PySide6.QtGui import QFont, QPainterPath +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() - path.addText(0, 0, font, text) + 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) - #print(elem.x) - #print(elem.y) + #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] diff --git a/src/main.py b/src/main.py index 5bde308..cb34124 100644 --- a/src/main.py +++ b/src/main.py @@ -115,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) @@ -293,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 From d15b6780760531ad1846426c8911ca6d185dd4c0 Mon Sep 17 00:00:00 2001 From: Hannes Diethelm Date: Wed, 25 Feb 2026 21:32:18 +0100 Subject: [PATCH 5/5] Update doc / gcode pre and postfix / fixes --- README.md | 6 +++--- src/helper.py | 10 ++++------ src/main.py | 17 +++++++++++++++-- src/main_cli.py | 25 ++++++++++++++++++------- 4 files changed, 40 insertions(+), 18 deletions(-) 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 index f8facb5..c3cdad9 100644 --- a/src/helper.py +++ b/src/helper.py @@ -27,11 +27,8 @@ def elem2xy(elem: QPainterPath.Element, scale=0.1, x_offset=0.0, y_offset=0.0): # 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): - gcode = [ - "G21 ; mm mode", - "G90 ; absolute positioning" - ] + 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) @@ -97,5 +94,6 @@ def path_to_gcode(path: QPainterPath, scale=0.1, safe_z=5.0, cut_z=0.0, feedrate if pen_down: gcode.append(f"G0 Z{safe_z + z_offset:.2f}") # Pen up at the end - gcode.append("M2 ; Program end") + gcode = gcode + g_postfix + return "\n".join(gcode) diff --git a/src/main.py b/src/main.py index cb34124..452e937 100644 --- a/src/main.py +++ b/src/main.py @@ -349,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) @@ -369,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 index e63de5d..52d9c35 100644 --- a/src/main_cli.py +++ b/src/main_cli.py @@ -9,8 +9,8 @@ if __name__ == "__main__": app = QApplication(sys.argv) - if len(sys.argv) != 4 and len(sys.argv) != 8: - print("Usage: main_cli.py font fontsize text [x y z feed]", file=sys.stderr) + 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] @@ -20,17 +20,26 @@ text = sys.argv[3] # Get offset and feedrate values - if len(sys.argv) == 8: + 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 @@ -38,9 +47,11 @@ 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) + x_offset=x_offset, y_offset=y_offset, z_offset=z_offset, feedrate=feedrate, + g_prefix=g_prefix, g_postfix=g_postfix) - print("; Text = " + sys.argv[1]) - print(f"; Dimensions: {width_mm:.2f} x {height_mm:.2f} mm") print(gcode)