Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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`

Expand Down Expand Up @@ -109,4 +109,4 @@ Copyright © 2025.
📡 vy 73 de OE7SET

---
Enjoy precise text-to-gcode conversion with a minimal, transparent codebase.
Enjoy precise text-to-gcode conversion with a minimal, transparent codebase.
99 changes: 99 additions & 0 deletions src/helper.py
Original file line number Diff line number Diff line change
@@ -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)
70 changes: 18 additions & 52 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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__()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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"

Expand Down
57 changes: 57 additions & 0 deletions src/main_cli.py
Original file line number Diff line number Diff line change
@@ -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)