Diagram Maker
Create flowcharts, mind maps, org charts, and swimlanes—fast. Drag, drop, connect, and export to PNG, PDF, or SVG. Free, offline, no tracking.
Source Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, math, sys, uuid
from dataclasses import dataclass, asdict
from PyQt5.QtCore import (Qt, QPointF, QRectF, QLineF, QSizeF, QObject, pyqtSignal, QMarginsF)
from PyQt5.QtGui import (QBrush, QColor, QPen, QPainterPath, QPainter, QTransform, QFont, QIcon, QKeySequence)
from PyQt5.QtSvg import QSvgGenerator
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsItem,QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsPathItem, QGraphicsSimpleTextItem,
QStyleOptionGraphicsItem, QFileDialog, QToolBar, QAction, QColorDialog, QSpinBox,QComboBox, QWidget, QHBoxLayout, QLabel, QMenu, QMessageBox, QUndoStack, QUndoCommand)
def new_id(): return str(uuid.uuid4())[:8]
def lerp(a,b,t): return a + (b-a)*t
def snap(v, step): return round(v/step)*step
class PortItem(QGraphicsEllipseItem):
def __init__(self, parent, key, pos):
super().__init__(-4, -4, 8, 8, parent)
self.setBrush(QBrush(QColor("#0ea5e9")))
self.setPen(QPen(QColor("#075985")))
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
self.key = key
self.setPos(pos)
class EdgeItem(QGraphicsPathItem):
def __init__(self, src=None, dst=None):
super().__init__()
self.id = new_id()
self.src = src
self.dst = dst
self.hover = False
self.setZValue(-1)
self.setPen(QPen(QColor("#111827"), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.updatePath()
def shape(self):
stroker = QPainterPath()
p = self.path()
s = QPainterPath()
s.addPath(p)
return s
def updatePath(self, temp_pos=None):
if not self.src: return
p1 = self.src.scenePos()
if self.dst:
p2 = self.dst.scenePos()
else:
p2 = temp_pos if temp_pos else p1
path = QPainterPath(p1)
dx = abs(p2.x()-p1.x())
c1 = QPointF(lerp(p1.x(), p2.x(), 0.5), p1.y())
c2 = QPointF(lerp(p1.x(), p2.x(), 0.5), p2.y())
if dx < 60:
c1 = QPointF(p1.x(), lerp(p1.y(), p2.y(), .5))
c2 = QPointF(p2.x(), lerp(p1.y(), p2.y(), .5))
path.cubicTo(c1, c2, p2)
self.setPath(path)
class NodeItem(QGraphicsItem):
TypeRect, TypeEllipse, TypeDiamond, TypeNote = range(4)
def __init__(self, node_type=TypeRect, text="Node", pos=QPointF(0,0), size=QSizeF(160, 80), fill="#ffffff", stroke="#111827", stroke_width=2):
super().__init__()
self.id = new_id()
self.node_type = node_type
self.rect = QRectF(-size.width()/2, -size.height()/2, size.width(), size.height())
self.fill = QColor(fill)
self.stroke = QColor(stroke)
self.stroke_width = stroke_width
self.ports = {}
self.textItem = QGraphicsSimpleTextItem(text, self)
self.textItem.setBrush(QBrush(QColor("#111827")))
f = QFont()
f.setPointSize(10)
self.textItem.setFont(f)
self.textItem.setPos(-self.textItem.boundingRect().width()/2, -self.textItem.boundingRect().height()/2)
self.setFlags(
QGraphicsItem.ItemIsMovable |
QGraphicsItem.ItemIsSelectable |
QGraphicsItem.ItemSendsGeometryChanges
)
self.setAcceptHoverEvents(True)
self.buildPorts()
self.setPos(pos)
def buildPorts(self):
self.ports.clear()
r = self.rect
pts = {
"N": QPointF(0, r.top()),
"S": QPointF(0, r.bottom()),
"E": QPointF(r.right(), 0),
"W": QPointF(r.left(), 0)
}
for k,p in pts.items():
port = PortItem(self, k, p)
port.setVisible(False)
self.ports[k] = port
def hoverEnterEvent(self, e):
for p in self.ports.values(): p.setVisible(True)
def hoverLeaveEvent(self, e):
if not self.isSelected():
for p in self.ports.values(): p.setVisible(False)
def mouseDoubleClickEvent(self, e):
if e.button() == Qt.LeftButton:
self.start_edit()
else:
super().mouseDoubleClickEvent(e)
def start_edit(self):
from PyQt5.QtWidgets import QLineEdit, QGraphicsProxyWidget
if getattr(self, "_editor", None): return
editor = QLineEdit(self.textItem.text())
editor.setAlignment(Qt.AlignCenter)
proxy = QGraphicsProxyWidget(self)
proxy.setWidget(editor)
br = self.textItem.boundingRect()
proxy.setPos(-max(120, br.width()+30)/2, -16)
proxy.resize(max(120, br.width()+30), 32)
editor.setFocus()
def commit():
self.textItem.setText(editor.text())
self.textItem.setPos(-self.textItem.boundingRect().width()/2, -self.textItem.boundingRect().height()/2)
proxy.setParentItem(None); proxy.deleteLater()
self._editor = None
def abort():
proxy.setParentItem(None); proxy.deleteLater()
self._editor = None
editor.editingFinished.connect(commit)
self._editor = proxy
def boundingRect(self):
m = 12
return self.rect.adjusted(-m,-m,m,m)
def shape(self):
path = QPainterPath()
if self.node_type == self.TypeRect or self.node_type == self.TypeNote:
r = self.rect.adjusted(0,0,0,0)
path.addRoundedRect(r, 12, 12)
elif self.node_type == self.TypeEllipse:
path.addEllipse(self.rect)
else:
r = self.rect
pts = [QPointF(0, r.top()), QPointF(r.right(),0), QPointF(0, r.bottom()), QPointF(r.left(),0)]
path.moveTo(pts[0])
for p in pts[1:]: path.lineTo(p)
path.closeSubpath()
return path
def paint(self, p: QPainter, option: QStyleOptionGraphicsItem, widget=None):
p.setRenderHint(QPainter.Antialiasing, True)
p.setPen(QPen(self.stroke, self.stroke_width))
p.setBrush(QBrush(self.fill))
if self.node_type == self.TypeRect:
p.drawRoundedRect(self.rect, 12, 12)
elif self.node_type == self.TypeEllipse:
p.drawEllipse(self.rect)
elif self.node_type == self.TypeDiamond:
r = self.rect
path = QPainterPath()
pts = [QPointF(0, r.top()), QPointF(r.right(),0), QPointF(0, r.bottom()), QPointF(r.left(),0)]
path.moveTo(pts[0])
for pt in pts[1:]: path.lineTo(pt)
path.closeSubpath()
p.drawPath(path)
else:
r = self.rect
ear = min(16, r.width()/5)
path = QPainterPath(r.topLeft())
path.lineTo(r.topRight() - QPointF(ear,0))
path.lineTo(r.topRight() + QPointF(0,ear))
path.lineTo(r.bottomRight())
path.lineTo(r.bottomLeft())
path.closeSubpath()
p.drawPath(path)
p.drawLine(r.topRight() - QPointF(ear,0), r.topRight() + QPointF(0,ear))
if self.isSelected():
sel = QColor("#3b82f6")
sel.setAlpha(60)
p.setPen(Qt.NoPen)
p.setBrush(sel)
p.drawPath(self.shape())
def itemChange(self, change, value):
if change == QGraphicsItem.ItemPositionChange:
for edge in getattr(self, "_edges", []):
edge.updatePath()
if change == QGraphicsItem.ItemSelectedHasChanged:
visible = self.isSelected()
for p in self.ports.values(): p.setVisible(visible)
return super().itemChange(change, value)
def serialize(self):
return {
"id": self.id,
"type": self.node_type,
"pos": [self.pos().x(), self.pos().y()],
"rect": [self.rect.x(), self.rect.y(), self.rect.width(), self.rect.height()],
"fill": self.fill.name(),
"stroke": self.stroke.name(),
"stroke_width": self.stroke_width,
"text": self.textItem.text()
}
def apply(self, d):
self.id = d["id"]
self.node_type = d["type"]
self.setPos(QPointF(*d["pos"]))
x,y,w,h = d["rect"]; self.rect = QRectF(x,y,w,h)
self.fill = QColor(d["fill"]); self.stroke = QColor(d["stroke"]); self.stroke_width = d["stroke_width"]
self.textItem.setText(d["text"])
self.textItem.setPos(-self.textItem.boundingRect().width()/2, -self.textItem.boundingRect().height()/2)
self.prepareGeometryChange()
self.buildPorts()
class DiagramScene(QGraphicsScene):
requestStatus = pyqtSignal(str)
MODE_SELECT, MODE_PAN, MODE_CONNECT, MODE_ADD_RECT, MODE_ADD_ELLIPSE, MODE_ADD_DIAMOND, MODE_ADD_NOTE = range(7)
def __init__(self, undo: QUndoStack):
super().__init__()
self.setSceneRect(-5000, -5000, 10000, 10000)
self.mode = self.MODE_SELECT
self.gridSize = 12
self.snapEnabled = True
self.undo = undo
self._temp_edge = None
self._edge_src_port = None
self._rubber = None
def drawBackground(self, painter: QPainter, rect: QRectF):
painter.fillRect(rect, QColor("#f7f8fb"))
left = int(math.floor(rect.left()))
top = int(math.floor(rect.top()))
right = int(math.ceil(rect.right()))
bottom = int(math.ceil(rect.bottom()))
grid = self.gridSize
pen_minor = QPen(QColor(0,0,0,20))
painter.setPen(pen_minor)
x = left - (left % grid)
while x < right:
painter.drawLine(x, top, x, bottom)
x += grid
y = top - (top % grid)
while y < bottom:
painter.drawLine(left, y, right, y)
y += grid
pen_major = QPen(QColor(0,0,0,40))
painter.setPen(pen_major)
step = grid*5
x = left - (left % step)
while x < right:
painter.drawLine(x, top, x, bottom)
x += step
y = top - (top % step)
while y < bottom:
painter.drawLine(left, y, right, y)
y += step
def setMode(self, m):
self.mode = m
names = {
self.MODE_SELECT:"Select",
self.MODE_PAN:"Pan",
self.MODE_CONNECT:"Connect",
self.MODE_ADD_RECT:"Add Rect",
self.MODE_ADD_ELLIPSE:"Add Ellipse",
self.MODE_ADD_DIAMOND:"Add Diamond",
self.MODE_ADD_NOTE:"Add Note"
}
self.requestStatus.emit(f"Mode: {names.get(m,'?')}")
def mousePressEvent(self, e):
if self.mode == self.MODE_PAN:
self.views()[0].setDragMode(QGraphicsView.ScrollHandDrag)
super().mousePressEvent(e); return
if self.mode in (self.MODE_ADD_RECT, self.MODE_ADD_ELLIPSE, self.MODE_ADD_DIAMOND, self.MODE_ADD_NOTE):
p = e.scenePos()
if self.snapEnabled: p = QPointF(snap(p.x(), self.gridSize), snap(p.y(), self.gridSize))
t = {
self.MODE_ADD_RECT: NodeItem.TypeRect,
self.MODE_ADD_ELLIPSE: NodeItem.TypeEllipse,
self.MODE_ADD_DIAMOND: NodeItem.TypeDiamond,
self.MODE_ADD_NOTE: NodeItem.TypeNote
}[self.mode]
cmd = AddNodeCommand(self, t, p)
self.undo.push(cmd)
e.accept(); return
if self.mode == self.MODE_CONNECT and e.button() == Qt.LeftButton:
it = self.itemAt(e.scenePos(), QTransform())
if isinstance(it, PortItem):
self._edge_src_port = it
self._temp_edge = EdgeItem(src=it)
self.addItem(self._temp_edge)
self._temp_edge.updatePath(e.scenePos())
for node in self.items():
if isinstance(node, NodeItem):
for p in node.ports.values():
p.setVisible(True)
e.accept(); return
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
if self._temp_edge:
self._temp_edge.updatePath(e.scenePos())
e.accept(); return
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
if self.mode == self.MODE_PAN:
self.views()[0].setDragMode(QGraphicsView.NoDrag)
if self._temp_edge and e.button() == Qt.LeftButton:
dstitem = self.itemAt(e.scenePos(), QTransform())
if isinstance(dstitem, PortItem) and dstitem is not self._edge_src_port:
cmd = AddEdgeCommand(self, self._edge_src_port, dstitem)
self.undo.push(cmd)
self.removeItem(self._temp_edge)
self._temp_edge = None; self._edge_src_port = None
for node in self.items():
if isinstance(node, NodeItem) and not node.isSelected():
for p in node.ports.values():
p.setVisible(False)
e.accept(); return
super().mouseReleaseEvent(e)
def keyPressEvent(self, e):
if e.key() == Qt.Key_Delete:
items = [i for i in self.selectedItems()]
if items:
self.undo.push(DeleteItemsCommand(self, items))
return
super().keyPressEvent(e)
def addNode(self, node_type, pos, init=False):
node = NodeItem(node_type=node_type, pos=pos)
self.addItem(node)
node._edges = []
if not init: node.setSelected(True)
return node
def addEdge(self, src_port: PortItem, dst_port: PortItem, init=False):
edge = EdgeItem(src_port, dst_port)
self.addItem(edge)
src_node = src_port.parentItem()
dst_node = dst_port.parentItem()
for n in (src_node, dst_node):
if not hasattr(n, "_edges"): n._edges = []
src_node._edges.append(edge)
dst_node._edges.append(edge)
edge.updatePath()
if not init: edge.setSelected(True)
return edge
def deleteItems(self, items):
edges = [i for i in items if isinstance(i, EdgeItem)]
nodes = [i for i in items if isinstance(i, NodeItem)]
for e in edges:
self.removeItem(e)
for n in self.items():
if isinstance(n, NodeItem) and hasattr(n, "_edges") and e in n._edges:
n._edges.remove(e)
for n in nodes:
if hasattr(n, "_edges"):
for e in list(n._edges):
if e.scene(): self.removeItem(e)
n._edges.clear()
self.removeItem(n)
def serialize(self):
nodes = []
edges = []
for it in self.items():
if isinstance(it, NodeItem):
nodes.append(it.serialize())
def port_key(port: PortItem):
return f"{port.parentItem().id}:{port.key}"
for it in self.items():
if isinstance(it, EdgeItem) and it.src and it.dst:
edges.append({
"id": it.id,
"src": port_key(it.src),
"dst": port_key(it.dst),
"stroke": it.pen().color().name(),
"width": it.pen().widthF()
})
return {"nodes": nodes, "edges": edges}
def load_from(self, data):
self.clear()
idmap = {}
for nd in data.get("nodes", []):
n = NodeItem()
n.apply(nd)
self.addItem(n)
n._edges = []
idmap[n.id] = n
for ed in data.get("edges", []):
try:
s_node, s_port = ed["src"].split(":")
d_node, d_port = ed["dst"].split(":")
src = idmap[s_node].ports[s_port]
dst = idmap[d_node].ports[d_port]
e = self.addEdge(src, dst, init=True)
e.setPen(QPen(QColor(ed.get("stroke","#111827")), float(ed.get("width",2))))
except Exception:
continue
class AddNodeCommand(QUndoCommand):
def __init__(self, scene: DiagramScene, node_type, pos):
super().__init__("Add Node")
self.scene = scene; self.node_type = node_type; self.pos = pos
self.node = None
def redo(self):
if not self.node:
self.node = self.scene.addNode(self.node_type, self.pos)
else:
self.scene.addItem(self.node)
def undo(self):
self.scene.deleteItems([self.node])
class AddEdgeCommand(QUndoCommand):
def __init__(self, scene: DiagramScene, src_port, dst_port):
super().__init__("Add Edge")
self.scene=scene; self.src=src_port; self.dst=dst_port; self.edge=None
def redo(self):
if not self.edge:
self.edge = self.scene.addEdge(self.src, self.dst)
else:
self.scene.addItem(self.edge)
for n in (self.src.parentItem(), self.dst.parentItem()):
if not hasattr(n,"_edges"): n._edges=[]
self.src.parentItem()._edges.append(self.edge)
self.dst.parentItem()._edges.append(self.edge)
self.edge.updatePath()
def undo(self):
self.scene.deleteItems([self.edge])
class DeleteItemsCommand(QUndoCommand):
def __init__(self, scene: DiagramScene, items):
super().__init__("Delete")
self.scene=scene
self.items=list(items)
def redo(self):
self._store=[]
for it in self.items:
if isinstance(it, NodeItem):
self._store.append(("node", it.serialize()))
elif isinstance(it, EdgeItem):
ed = {
"id": it.id,
"src": f"{it.src.parentItem().id}:{it.src.key}",
"dst": f"{it.dst.parentItem().id}:{it.dst.key}",
"stroke": it.pen().color().name(),
"width": it.pen().widthF()
}
self._store.append(("edge", ed))
self.scene.deleteItems(self.items)
def undo(self):
idmap={}
for t, payload in self._store:
if t=="node":
n = NodeItem()
n.apply(payload)
self.scene.addItem(n)
n._edges=[]
idmap[n.id]=n
for t, payload in self._store:
if t=="edge":
s_node, s_port = payload["src"].split(":")
d_node, d_port = payload["dst"].split(":")
src = idmap[s_node].ports[s_port]
dst = idmap[d_node].ports[d_port]
e = self.scene.addEdge(src, dst, init=True)
e.setPen(QPen(QColor(payload["stroke"]), float(payload["width"])))
class DiagramView(QGraphicsView):
def __init__(self, scene: DiagramScene):
super().__init__(scene)
self.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
self.setDragMode(QGraphicsView.NoDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.zoom = 1.0
def wheelEvent(self, e):
if e.modifiers() & Qt.ControlModifier:
delta = 1.15 if e.angleDelta().y()>0 else 1/1.15
self.zoomBy(delta)
else:
super().wheelEvent(e)
def zoomBy(self, factor):
self.zoom *= factor
self.scale(factor, factor)
def keyPressEvent(self, e):
if e.matches(QKeySequence.ZoomIn):
self.zoomBy(1.15); return
if e.matches(QKeySequence.ZoomOut):
self.zoomBy(1/1.15); return
super().keyPressEvent(e)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Diagram Maker — CheapITSupport.com")
self.resize(1200, 800)
self.undo = QUndoStack(self)
self.scene = DiagramScene(self.undo)
self.view = DiagramView(self.scene)
self.setCentralWidget(self.view)
self.status = self.statusBar()
self.scene.requestStatus.connect(self.status.showMessage)
self.make_toolbar()
self.new_doc()
def make_toolbar(self):
tb = QToolBar("Tools"); tb.setMovable(False)
self.addToolBar(Qt.TopToolBarArea, tb)
def act(text, cb, shortcut=None, checkable=False):
a = QAction(text, self); a.triggered.connect(cb); a.setCheckable(checkable)
if shortcut: a.setShortcut(shortcut)
tb.addAction(a); return a
self.group = []
sel = act("Select", lambda: self.set_mode(DiagramScene.MODE_SELECT), None, True); sel.setChecked(True); self.group.append(sel)
pan = act("Pan", lambda: self.set_mode(DiagramScene.MODE_PAN), "Space", True); self.group.append(pan)
conn = act("Connect", lambda: self.set_mode(DiagramScene.MODE_CONNECT), "C", True); self.group.append(conn)
tb.addSeparator()
r = act("Rect", lambda: self.set_mode(DiagramScene.MODE_ADD_RECT), "R", True); self.group.append(r)
e = act("Ellipse", lambda: self.set_mode(DiagramScene.MODE_ADD_ELLIPSE), "E", True); self.group.append(e)
d = act("Diamond", lambda: self.set_mode(DiagramScene.MODE_ADD_DIAMOND), "D", True); self.group.append(d)
n = act("Note", lambda: self.set_mode(DiagramScene.MODE_ADD_NOTE), "T", True); self.group.append(n)
tb.addSeparator()
act("Color", self.pick_color)
self.widthSpin = QSpinBox(); self.widthSpin.setRange(1,8); self.widthSpin.setValue(2); self.widthSpin.valueChanged.connect(self.apply_line_width)
wWrap = QWidget(); h=QHBoxLayout(wWrap); h.setContentsMargins(0,0,0,0); h.addWidget(QLabel("Stroke")); h.addWidget(self.widthSpin); tb.addWidget(wWrap)
tb.addSeparator()
act("Undo", self.undo.undo, QKeySequence.Undo)
act("Redo", self.undo.redo, QKeySequence.Redo)
act("Copy", self.copy_sel, QKeySequence.Copy)
act("Paste", self.paste_sel, QKeySequence.Paste)
tb.addSeparator()
act("Fit", self.fit, "F")
act("100%", self.zoom100)
tb.addSeparator()
act("Save", self.save_json, QKeySequence.Save)
act("Load", self.load_json, QKeySequence.Open)
act("Export PNG", self.export_png)
act("Export SVG", self.export_svg)
act("Export PDF", self.export_pdf)
def set_mode(self, m):
for a in self.group: a.setChecked(False)
sender = self.sender()
if isinstance(sender, QAction): sender.setChecked(True)
self.scene.setMode(m)
def current_selection(self):
return self.scene.selectedItems()
def pick_color(self):
col = QColorDialog.getColor(QColor("#111827"), self, "Pick Stroke Color")
if not col.isValid(): return
for it in self.current_selection():
if isinstance(it, NodeItem):
it.stroke = col; it.update()
elif isinstance(it, EdgeItem):
pen = it.pen(); pen.setColor(col); it.setPen(pen)
def apply_line_width(self, v):
for it in self.current_selection():
if isinstance(it, NodeItem):
it.stroke_width = v; it.update()
elif isinstance(it, EdgeItem):
pen = it.pen(); pen.setWidth(v); it.setPen(pen)
def fit(self):
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
m = united.marginsAdded(QMarginsF(80,80,80,80))
self.view.fitInView(m, Qt.KeepAspectRatio)
self.view.zoom = 1.0
def zoom100(self):
self.view.resetTransform(); self.view.zoom = 1.0
def new_doc(self):
self.scene.clear()
a = self.scene.addNode(NodeItem.TypeRect, QPointF(-200,0), init=True)
a.textItem.setText("Start")
b = self.scene.addNode(NodeItem.TypeDiamond, QPointF(0,0), init=True); b.textItem.setText("Decision")
c = self.scene.addNode(NodeItem.TypeEllipse, QPointF(220,0), init=True); c.textItem.setText("End")
self.scene.addEdge(a.ports["E"], b.ports["W"], init=True)
self.scene.addEdge(b.ports["E"], c.ports["W"], init=True)
def copy_sel(self):
data = {"nodes":[], "edges":[]}
sels = self.current_selection()
nodes = [i for i in sels if isinstance(i, NodeItem)]
edges = [i for i in sels if isinstance(i, EdgeItem)]
for n in nodes: data["nodes"].append(n.serialize())
for e in edges:
data["edges"].append({
"src": f"{e.src.parentItem().id}:{e.src.key}",
"dst": f"{e.dst.parentItem().id}:{e.dst.key}",
"stroke": e.pen().color().name(), "width": e.pen().widthF()
})
QApplication.clipboard().setText(json.dumps(data))
def paste_sel(self):
try:
data = json.loads(QApplication.clipboard().text())
except Exception:
return
idmap={}
offset = QPointF(20,20)
for nd in data.get("nodes", []):
n = NodeItem()
nd["id"] = new_id()
nd["pos"] = [nd["pos"][0]+offset.x(), nd["pos"][1]+offset.y()]
n.apply(nd)
self.scene.addItem(n); n._edges=[]
idmap[nd["id"]] = n
for ed in data.get("edges", []):
pass
def save_json(self):
fn, _ = QFileDialog.getSaveFileName(self, "Save Diagram", "", "Diagram JSON (*.json)")
if not fn: return
with open(fn, "w", encoding="utf-8") as f:
json.dump(self.scene.serialize(), f, indent=2)
self.status.showMessage(f"Saved: {fn}", 3500)
def load_json(self):
fn, _ = QFileDialog.getOpenFileName(self, "Load Diagram", "", "Diagram JSON (*.json)")
if not fn: return
try:
with open(fn, "r", encoding="utf-8") as f:
data = json.load(f)
self.scene.load_from(data)
self.status.showMessage(f"Loaded: {fn}", 3500)
except Exception as ex:
QMessageBox.critical(self, "Load Failed", str(ex))
def export_png(self):
fn, _ = QFileDialog.getSaveFileName(self, "Export PNG", "", "PNG (*.png)")
if not fn: return
from PyQt5.QtGui import QImage
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
img = QImage(int(united.width()+160), int(united.height()+160), QImage.Format_ARGB32)
img.fill(Qt.white)
painter = QPainter(img)
painter.translate(-united.x()+80, -united.y()+80)
self.scene.render(painter, QRectF(img.rect()), united)
painter.end()
img.save(fn)
self.status.showMessage(f"Exported PNG: {fn}", 3500)
def export_svg(self):
fn, _ = QFileDialog.getSaveFileName(self, "Export SVG", "", "SVG (*.svg)")
if not fn: return
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
gen = QSvgGenerator()
gen.setFileName(fn)
gen.setSize(united.size().toSize())
gen.setViewBox(united.toRect())
painter = QPainter(gen)
self.scene.render(painter, united, united)
painter.end()
self.status.showMessage(f"Exported SVG: {fn}", 3500)
def export_pdf(self):
fn, _ = QFileDialog.getSaveFileName(self, "Export PDF", "", "PDF (*.pdf)")
if not fn: return
from PyQt5.QtPrintSupport import QPrinter
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
printer = QPrinter(QPrinter.HighResolution)
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName(fn)
painter = QPainter(printer)
view = self.view.rect()
self.scene.render(painter)
painter.end()
self.status.showMessage(f"Exported PDF: {fn}", 3500)
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
Why you’ll like it
Rectangles, ellipses, diamonds, and notes with rounded corners.
Magnetic ports, tidy curves, auto-reroute when you move nodes.
Double-click to edit labels. Undo/redo for every change.
Clean alignment without fiddling. Pan/zoom with smooth rendering.
PNG for slides, SVG for devs, PDF for print—one click.
No network calls. Save diagrams as JSON files locally.
Preview: shortcuts & actions
Tools: [Select] [Pan (Space)] [Connect (C)] [Rect (R)] [Ellipse (E)] [Diamond (D)] [Note (T)] Edit: Double-click text • Delete = remove • Ctrl+Z / Ctrl+Y = Undo/Redo View: Ctrl + Wheel = Zoom • F = Fit to content • 100% button resets zoom Export: PNG • SVG • PDF
How it works
Download and run. No admin required—uses your user profile.
Add shapes, drag to connect ports, and label your steps inline.
Export to PNG/SVG/PDF, or save/load JSON to keep editing later.
FAQ
Does it work offline?
Yes. There are no network requests—everything runs locally.
Do I need admin rights?
No. The app runs from your user directory and stores files there.
Where are diagrams stored?
As JSON files you can save anywhere. Reopen to keep editing.
Will SVG/PDF keep vectors?
Yes—SVG stays vector; PDF exports are high-quality for print.
Can I build it myself?
Yes. Requires Python 3 + PyQt5. Build a single EXE with:
pyinstaller --onefile --noconsole --icon NONE diagram_maker.py
Download options
Source Code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, math, sys, uuid
from dataclasses import dataclass, asdict
from PyQt5.QtCore import (Qt, QPointF, QRectF, QLineF, QSizeF, QObject, pyqtSignal, QMarginsF)
from PyQt5.QtGui import (QBrush, QColor, QPen, QPainterPath, QPainter, QTransform, QFont, QIcon, QKeySequence)
from PyQt5.QtSvg import QSvgGenerator
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsItem,QGraphicsRectItem, QGraphicsEllipseItem, QGraphicsPathItem, QGraphicsSimpleTextItem,
QStyleOptionGraphicsItem, QFileDialog, QToolBar, QAction, QColorDialog, QSpinBox,QComboBox, QWidget, QHBoxLayout, QLabel, QMenu, QMessageBox, QUndoStack, QUndoCommand)
def new_id(): return str(uuid.uuid4())[:8]
def lerp(a,b,t): return a + (b-a)*t
def snap(v, step): return round(v/step)*step
class PortItem(QGraphicsEllipseItem):
def __init__(self, parent, key, pos):
super().__init__(-4, -4, 8, 8, parent)
self.setBrush(QBrush(QColor("#0ea5e9")))
self.setPen(QPen(QColor("#075985")))
self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
self.key = key
self.setPos(pos)
class EdgeItem(QGraphicsPathItem):
def __init__(self, src=None, dst=None):
super().__init__()
self.id = new_id()
self.src = src
self.dst = dst
self.hover = False
self.setZValue(-1)
self.setPen(QPen(QColor("#111827"), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.updatePath()
def shape(self):
stroker = QPainterPath()
p = self.path()
s = QPainterPath()
s.addPath(p)
return s
def updatePath(self, temp_pos=None):
if not self.src: return
p1 = self.src.scenePos()
if self.dst:
p2 = self.dst.scenePos()
else:
p2 = temp_pos if temp_pos else p1
path = QPainterPath(p1)
dx = abs(p2.x()-p1.x())
c1 = QPointF(lerp(p1.x(), p2.x(), 0.5), p1.y())
c2 = QPointF(lerp(p1.x(), p2.x(), 0.5), p2.y())
if dx < 60:
c1 = QPointF(p1.x(), lerp(p1.y(), p2.y(), .5))
c2 = QPointF(p2.x(), lerp(p1.y(), p2.y(), .5))
path.cubicTo(c1, c2, p2)
self.setPath(path)
class NodeItem(QGraphicsItem):
TypeRect, TypeEllipse, TypeDiamond, TypeNote = range(4)
def __init__(self, node_type=TypeRect, text="Node", pos=QPointF(0,0), size=QSizeF(160, 80), fill="#ffffff", stroke="#111827", stroke_width=2):
super().__init__()
self.id = new_id()
self.node_type = node_type
self.rect = QRectF(-size.width()/2, -size.height()/2, size.width(), size.height())
self.fill = QColor(fill)
self.stroke = QColor(stroke)
self.stroke_width = stroke_width
self.ports = {}
self.textItem = QGraphicsSimpleTextItem(text, self)
self.textItem.setBrush(QBrush(QColor("#111827")))
f = QFont()
f.setPointSize(10)
self.textItem.setFont(f)
self.textItem.setPos(-self.textItem.boundingRect().width()/2, -self.textItem.boundingRect().height()/2)
self.setFlags(
QGraphicsItem.ItemIsMovable |
QGraphicsItem.ItemIsSelectable |
QGraphicsItem.ItemSendsGeometryChanges
)
self.setAcceptHoverEvents(True)
self.buildPorts()
self.setPos(pos)
def buildPorts(self):
self.ports.clear()
r = self.rect
pts = {
"N": QPointF(0, r.top()),
"S": QPointF(0, r.bottom()),
"E": QPointF(r.right(), 0),
"W": QPointF(r.left(), 0)
}
for k,p in pts.items():
port = PortItem(self, k, p)
port.setVisible(False)
self.ports[k] = port
def hoverEnterEvent(self, e):
for p in self.ports.values(): p.setVisible(True)
def hoverLeaveEvent(self, e):
if not self.isSelected():
for p in self.ports.values(): p.setVisible(False)
def mouseDoubleClickEvent(self, e):
if e.button() == Qt.LeftButton:
self.start_edit()
else:
super().mouseDoubleClickEvent(e)
def start_edit(self):
from PyQt5.QtWidgets import QLineEdit, QGraphicsProxyWidget
if getattr(self, "_editor", None): return
editor = QLineEdit(self.textItem.text())
editor.setAlignment(Qt.AlignCenter)
proxy = QGraphicsProxyWidget(self)
proxy.setWidget(editor)
br = self.textItem.boundingRect()
proxy.setPos(-max(120, br.width()+30)/2, -16)
proxy.resize(max(120, br.width()+30), 32)
editor.setFocus()
def commit():
self.textItem.setText(editor.text())
self.textItem.setPos(-self.textItem.boundingRect().width()/2, -self.textItem.boundingRect().height()/2)
proxy.setParentItem(None); proxy.deleteLater()
self._editor = None
def abort():
proxy.setParentItem(None); proxy.deleteLater()
self._editor = None
editor.editingFinished.connect(commit)
self._editor = proxy
def boundingRect(self):
m = 12
return self.rect.adjusted(-m,-m,m,m)
def shape(self):
path = QPainterPath()
if self.node_type == self.TypeRect or self.node_type == self.TypeNote:
r = self.rect.adjusted(0,0,0,0)
path.addRoundedRect(r, 12, 12)
elif self.node_type == self.TypeEllipse:
path.addEllipse(self.rect)
else:
r = self.rect
pts = [QPointF(0, r.top()), QPointF(r.right(),0), QPointF(0, r.bottom()), QPointF(r.left(),0)]
path.moveTo(pts[0])
for p in pts[1:]: path.lineTo(p)
path.closeSubpath()
return path
def paint(self, p: QPainter, option: QStyleOptionGraphicsItem, widget=None):
p.setRenderHint(QPainter.Antialiasing, True)
p.setPen(QPen(self.stroke, self.stroke_width))
p.setBrush(QBrush(self.fill))
if self.node_type == self.TypeRect:
p.drawRoundedRect(self.rect, 12, 12)
elif self.node_type == self.TypeEllipse:
p.drawEllipse(self.rect)
elif self.node_type == self.TypeDiamond:
r = self.rect
path = QPainterPath()
pts = [QPointF(0, r.top()), QPointF(r.right(),0), QPointF(0, r.bottom()), QPointF(r.left(),0)]
path.moveTo(pts[0])
for pt in pts[1:]: path.lineTo(pt)
path.closeSubpath()
p.drawPath(path)
else:
r = self.rect
ear = min(16, r.width()/5)
path = QPainterPath(r.topLeft())
path.lineTo(r.topRight() - QPointF(ear,0))
path.lineTo(r.topRight() + QPointF(0,ear))
path.lineTo(r.bottomRight())
path.lineTo(r.bottomLeft())
path.closeSubpath()
p.drawPath(path)
p.drawLine(r.topRight() - QPointF(ear,0), r.topRight() + QPointF(0,ear))
if self.isSelected():
sel = QColor("#3b82f6")
sel.setAlpha(60)
p.setPen(Qt.NoPen)
p.setBrush(sel)
p.drawPath(self.shape())
def itemChange(self, change, value):
if change == QGraphicsItem.ItemPositionChange:
for edge in getattr(self, "_edges", []):
edge.updatePath()
if change == QGraphicsItem.ItemSelectedHasChanged:
visible = self.isSelected()
for p in self.ports.values(): p.setVisible(visible)
return super().itemChange(change, value)
def serialize(self):
return {
"id": self.id,
"type": self.node_type,
"pos": [self.pos().x(), self.pos().y()],
"rect": [self.rect.x(), self.rect.y(), self.rect.width(), self.rect.height()],
"fill": self.fill.name(),
"stroke": self.stroke.name(),
"stroke_width": self.stroke_width,
"text": self.textItem.text()
}
def apply(self, d):
self.id = d["id"]
self.node_type = d["type"]
self.setPos(QPointF(*d["pos"]))
x,y,w,h = d["rect"]; self.rect = QRectF(x,y,w,h)
self.fill = QColor(d["fill"]); self.stroke = QColor(d["stroke"]); self.stroke_width = d["stroke_width"]
self.textItem.setText(d["text"])
self.textItem.setPos(-self.textItem.boundingRect().width()/2, -self.textItem.boundingRect().height()/2)
self.prepareGeometryChange()
self.buildPorts()
class DiagramScene(QGraphicsScene):
requestStatus = pyqtSignal(str)
MODE_SELECT, MODE_PAN, MODE_CONNECT, MODE_ADD_RECT, MODE_ADD_ELLIPSE, MODE_ADD_DIAMOND, MODE_ADD_NOTE = range(7)
def __init__(self, undo: QUndoStack):
super().__init__()
self.setSceneRect(-5000, -5000, 10000, 10000)
self.mode = self.MODE_SELECT
self.gridSize = 12
self.snapEnabled = True
self.undo = undo
self._temp_edge = None
self._edge_src_port = None
self._rubber = None
def drawBackground(self, painter: QPainter, rect: QRectF):
painter.fillRect(rect, QColor("#f7f8fb"))
left = int(math.floor(rect.left()))
top = int(math.floor(rect.top()))
right = int(math.ceil(rect.right()))
bottom = int(math.ceil(rect.bottom()))
grid = self.gridSize
pen_minor = QPen(QColor(0,0,0,20))
painter.setPen(pen_minor)
x = left - (left % grid)
while x < right:
painter.drawLine(x, top, x, bottom)
x += grid
y = top - (top % grid)
while y < bottom:
painter.drawLine(left, y, right, y)
y += grid
pen_major = QPen(QColor(0,0,0,40))
painter.setPen(pen_major)
step = grid*5
x = left - (left % step)
while x < right:
painter.drawLine(x, top, x, bottom)
x += step
y = top - (top % step)
while y < bottom:
painter.drawLine(left, y, right, y)
y += step
def setMode(self, m):
self.mode = m
names = {
self.MODE_SELECT:"Select",
self.MODE_PAN:"Pan",
self.MODE_CONNECT:"Connect",
self.MODE_ADD_RECT:"Add Rect",
self.MODE_ADD_ELLIPSE:"Add Ellipse",
self.MODE_ADD_DIAMOND:"Add Diamond",
self.MODE_ADD_NOTE:"Add Note"
}
self.requestStatus.emit(f"Mode: {names.get(m,'?')}")
def mousePressEvent(self, e):
if self.mode == self.MODE_PAN:
self.views()[0].setDragMode(QGraphicsView.ScrollHandDrag)
super().mousePressEvent(e); return
if self.mode in (self.MODE_ADD_RECT, self.MODE_ADD_ELLIPSE, self.MODE_ADD_DIAMOND, self.MODE_ADD_NOTE):
p = e.scenePos()
if self.snapEnabled: p = QPointF(snap(p.x(), self.gridSize), snap(p.y(), self.gridSize))
t = {
self.MODE_ADD_RECT: NodeItem.TypeRect,
self.MODE_ADD_ELLIPSE: NodeItem.TypeEllipse,
self.MODE_ADD_DIAMOND: NodeItem.TypeDiamond,
self.MODE_ADD_NOTE: NodeItem.TypeNote
}[self.mode]
cmd = AddNodeCommand(self, t, p)
self.undo.push(cmd)
e.accept(); return
if self.mode == self.MODE_CONNECT and e.button() == Qt.LeftButton:
it = self.itemAt(e.scenePos(), QTransform())
if isinstance(it, PortItem):
self._edge_src_port = it
self._temp_edge = EdgeItem(src=it)
self.addItem(self._temp_edge)
self._temp_edge.updatePath(e.scenePos())
for node in self.items():
if isinstance(node, NodeItem):
for p in node.ports.values():
p.setVisible(True)
e.accept(); return
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
if self._temp_edge:
self._temp_edge.updatePath(e.scenePos())
e.accept(); return
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
if self.mode == self.MODE_PAN:
self.views()[0].setDragMode(QGraphicsView.NoDrag)
if self._temp_edge and e.button() == Qt.LeftButton:
dstitem = self.itemAt(e.scenePos(), QTransform())
if isinstance(dstitem, PortItem) and dstitem is not self._edge_src_port:
cmd = AddEdgeCommand(self, self._edge_src_port, dstitem)
self.undo.push(cmd)
self.removeItem(self._temp_edge)
self._temp_edge = None; self._edge_src_port = None
for node in self.items():
if isinstance(node, NodeItem) and not node.isSelected():
for p in node.ports.values():
p.setVisible(False)
e.accept(); return
super().mouseReleaseEvent(e)
def keyPressEvent(self, e):
if e.key() == Qt.Key_Delete:
items = [i for i in self.selectedItems()]
if items:
self.undo.push(DeleteItemsCommand(self, items))
return
super().keyPressEvent(e)
def addNode(self, node_type, pos, init=False):
node = NodeItem(node_type=node_type, pos=pos)
self.addItem(node)
node._edges = []
if not init: node.setSelected(True)
return node
def addEdge(self, src_port: PortItem, dst_port: PortItem, init=False):
edge = EdgeItem(src_port, dst_port)
self.addItem(edge)
src_node = src_port.parentItem()
dst_node = dst_port.parentItem()
for n in (src_node, dst_node):
if not hasattr(n, "_edges"): n._edges = []
src_node._edges.append(edge)
dst_node._edges.append(edge)
edge.updatePath()
if not init: edge.setSelected(True)
return edge
def deleteItems(self, items):
edges = [i for i in items if isinstance(i, EdgeItem)]
nodes = [i for i in items if isinstance(i, NodeItem)]
for e in edges:
self.removeItem(e)
for n in self.items():
if isinstance(n, NodeItem) and hasattr(n, "_edges") and e in n._edges:
n._edges.remove(e)
for n in nodes:
if hasattr(n, "_edges"):
for e in list(n._edges):
if e.scene(): self.removeItem(e)
n._edges.clear()
self.removeItem(n)
def serialize(self):
nodes = []
edges = []
for it in self.items():
if isinstance(it, NodeItem):
nodes.append(it.serialize())
def port_key(port: PortItem):
return f"{port.parentItem().id}:{port.key}"
for it in self.items():
if isinstance(it, EdgeItem) and it.src and it.dst:
edges.append({
"id": it.id,
"src": port_key(it.src),
"dst": port_key(it.dst),
"stroke": it.pen().color().name(),
"width": it.pen().widthF()
})
return {"nodes": nodes, "edges": edges}
def load_from(self, data):
self.clear()
idmap = {}
for nd in data.get("nodes", []):
n = NodeItem()
n.apply(nd)
self.addItem(n)
n._edges = []
idmap[n.id] = n
for ed in data.get("edges", []):
try:
s_node, s_port = ed["src"].split(":")
d_node, d_port = ed["dst"].split(":")
src = idmap[s_node].ports[s_port]
dst = idmap[d_node].ports[d_port]
e = self.addEdge(src, dst, init=True)
e.setPen(QPen(QColor(ed.get("stroke","#111827")), float(ed.get("width",2))))
except Exception:
continue
class AddNodeCommand(QUndoCommand):
def __init__(self, scene: DiagramScene, node_type, pos):
super().__init__("Add Node")
self.scene = scene; self.node_type = node_type; self.pos = pos
self.node = None
def redo(self):
if not self.node:
self.node = self.scene.addNode(self.node_type, self.pos)
else:
self.scene.addItem(self.node)
def undo(self):
self.scene.deleteItems([self.node])
class AddEdgeCommand(QUndoCommand):
def __init__(self, scene: DiagramScene, src_port, dst_port):
super().__init__("Add Edge")
self.scene=scene; self.src=src_port; self.dst=dst_port; self.edge=None
def redo(self):
if not self.edge:
self.edge = self.scene.addEdge(self.src, self.dst)
else:
self.scene.addItem(self.edge)
for n in (self.src.parentItem(), self.dst.parentItem()):
if not hasattr(n,"_edges"): n._edges=[]
self.src.parentItem()._edges.append(self.edge)
self.dst.parentItem()._edges.append(self.edge)
self.edge.updatePath()
def undo(self):
self.scene.deleteItems([self.edge])
class DeleteItemsCommand(QUndoCommand):
def __init__(self, scene: DiagramScene, items):
super().__init__("Delete")
self.scene=scene
self.items=list(items)
def redo(self):
self._store=[]
for it in self.items:
if isinstance(it, NodeItem):
self._store.append(("node", it.serialize()))
elif isinstance(it, EdgeItem):
ed = {
"id": it.id,
"src": f"{it.src.parentItem().id}:{it.src.key}",
"dst": f"{it.dst.parentItem().id}:{it.dst.key}",
"stroke": it.pen().color().name(),
"width": it.pen().widthF()
}
self._store.append(("edge", ed))
self.scene.deleteItems(self.items)
def undo(self):
idmap={}
for t, payload in self._store:
if t=="node":
n = NodeItem()
n.apply(payload)
self.scene.addItem(n)
n._edges=[]
idmap[n.id]=n
for t, payload in self._store:
if t=="edge":
s_node, s_port = payload["src"].split(":")
d_node, d_port = payload["dst"].split(":")
src = idmap[s_node].ports[s_port]
dst = idmap[d_node].ports[d_port]
e = self.scene.addEdge(src, dst, init=True)
e.setPen(QPen(QColor(payload["stroke"]), float(payload["width"])))
class DiagramView(QGraphicsView):
def __init__(self, scene: DiagramScene):
super().__init__(scene)
self.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
self.setDragMode(QGraphicsView.NoDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.zoom = 1.0
def wheelEvent(self, e):
if e.modifiers() & Qt.ControlModifier:
delta = 1.15 if e.angleDelta().y()>0 else 1/1.15
self.zoomBy(delta)
else:
super().wheelEvent(e)
def zoomBy(self, factor):
self.zoom *= factor
self.scale(factor, factor)
def keyPressEvent(self, e):
if e.matches(QKeySequence.ZoomIn):
self.zoomBy(1.15); return
if e.matches(QKeySequence.ZoomOut):
self.zoomBy(1/1.15); return
super().keyPressEvent(e)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Diagram Maker — CheapITSupport.com")
self.resize(1200, 800)
self.undo = QUndoStack(self)
self.scene = DiagramScene(self.undo)
self.view = DiagramView(self.scene)
self.setCentralWidget(self.view)
self.status = self.statusBar()
self.scene.requestStatus.connect(self.status.showMessage)
self.make_toolbar()
self.new_doc()
def make_toolbar(self):
tb = QToolBar("Tools"); tb.setMovable(False)
self.addToolBar(Qt.TopToolBarArea, tb)
def act(text, cb, shortcut=None, checkable=False):
a = QAction(text, self); a.triggered.connect(cb); a.setCheckable(checkable)
if shortcut: a.setShortcut(shortcut)
tb.addAction(a); return a
self.group = []
sel = act("Select", lambda: self.set_mode(DiagramScene.MODE_SELECT), None, True); sel.setChecked(True); self.group.append(sel)
pan = act("Pan", lambda: self.set_mode(DiagramScene.MODE_PAN), "Space", True); self.group.append(pan)
conn = act("Connect", lambda: self.set_mode(DiagramScene.MODE_CONNECT), "C", True); self.group.append(conn)
tb.addSeparator()
r = act("Rect", lambda: self.set_mode(DiagramScene.MODE_ADD_RECT), "R", True); self.group.append(r)
e = act("Ellipse", lambda: self.set_mode(DiagramScene.MODE_ADD_ELLIPSE), "E", True); self.group.append(e)
d = act("Diamond", lambda: self.set_mode(DiagramScene.MODE_ADD_DIAMOND), "D", True); self.group.append(d)
n = act("Note", lambda: self.set_mode(DiagramScene.MODE_ADD_NOTE), "T", True); self.group.append(n)
tb.addSeparator()
act("Color", self.pick_color)
self.widthSpin = QSpinBox(); self.widthSpin.setRange(1,8); self.widthSpin.setValue(2); self.widthSpin.valueChanged.connect(self.apply_line_width)
wWrap = QWidget(); h=QHBoxLayout(wWrap); h.setContentsMargins(0,0,0,0); h.addWidget(QLabel("Stroke")); h.addWidget(self.widthSpin); tb.addWidget(wWrap)
tb.addSeparator()
act("Undo", self.undo.undo, QKeySequence.Undo)
act("Redo", self.undo.redo, QKeySequence.Redo)
act("Copy", self.copy_sel, QKeySequence.Copy)
act("Paste", self.paste_sel, QKeySequence.Paste)
tb.addSeparator()
act("Fit", self.fit, "F")
act("100%", self.zoom100)
tb.addSeparator()
act("Save", self.save_json, QKeySequence.Save)
act("Load", self.load_json, QKeySequence.Open)
act("Export PNG", self.export_png)
act("Export SVG", self.export_svg)
act("Export PDF", self.export_pdf)
def set_mode(self, m):
for a in self.group: a.setChecked(False)
sender = self.sender()
if isinstance(sender, QAction): sender.setChecked(True)
self.scene.setMode(m)
def current_selection(self):
return self.scene.selectedItems()
def pick_color(self):
col = QColorDialog.getColor(QColor("#111827"), self, "Pick Stroke Color")
if not col.isValid(): return
for it in self.current_selection():
if isinstance(it, NodeItem):
it.stroke = col; it.update()
elif isinstance(it, EdgeItem):
pen = it.pen(); pen.setColor(col); it.setPen(pen)
def apply_line_width(self, v):
for it in self.current_selection():
if isinstance(it, NodeItem):
it.stroke_width = v; it.update()
elif isinstance(it, EdgeItem):
pen = it.pen(); pen.setWidth(v); it.setPen(pen)
def fit(self):
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
m = united.marginsAdded(QMarginsF(80,80,80,80))
self.view.fitInView(m, Qt.KeepAspectRatio)
self.view.zoom = 1.0
def zoom100(self):
self.view.resetTransform(); self.view.zoom = 1.0
def new_doc(self):
self.scene.clear()
a = self.scene.addNode(NodeItem.TypeRect, QPointF(-200,0), init=True)
a.textItem.setText("Start")
b = self.scene.addNode(NodeItem.TypeDiamond, QPointF(0,0), init=True); b.textItem.setText("Decision")
c = self.scene.addNode(NodeItem.TypeEllipse, QPointF(220,0), init=True); c.textItem.setText("End")
self.scene.addEdge(a.ports["E"], b.ports["W"], init=True)
self.scene.addEdge(b.ports["E"], c.ports["W"], init=True)
def copy_sel(self):
data = {"nodes":[], "edges":[]}
sels = self.current_selection()
nodes = [i for i in sels if isinstance(i, NodeItem)]
edges = [i for i in sels if isinstance(i, EdgeItem)]
for n in nodes: data["nodes"].append(n.serialize())
for e in edges:
data["edges"].append({
"src": f"{e.src.parentItem().id}:{e.src.key}",
"dst": f"{e.dst.parentItem().id}:{e.dst.key}",
"stroke": e.pen().color().name(), "width": e.pen().widthF()
})
QApplication.clipboard().setText(json.dumps(data))
def paste_sel(self):
try:
data = json.loads(QApplication.clipboard().text())
except Exception:
return
idmap={}
offset = QPointF(20,20)
for nd in data.get("nodes", []):
n = NodeItem()
nd["id"] = new_id()
nd["pos"] = [nd["pos"][0]+offset.x(), nd["pos"][1]+offset.y()]
n.apply(nd)
self.scene.addItem(n); n._edges=[]
idmap[nd["id"]] = n
for ed in data.get("edges", []):
pass
def save_json(self):
fn, _ = QFileDialog.getSaveFileName(self, "Save Diagram", "", "Diagram JSON (*.json)")
if not fn: return
with open(fn, "w", encoding="utf-8") as f:
json.dump(self.scene.serialize(), f, indent=2)
self.status.showMessage(f"Saved: {fn}", 3500)
def load_json(self):
fn, _ = QFileDialog.getOpenFileName(self, "Load Diagram", "", "Diagram JSON (*.json)")
if not fn: return
try:
with open(fn, "r", encoding="utf-8") as f:
data = json.load(f)
self.scene.load_from(data)
self.status.showMessage(f"Loaded: {fn}", 3500)
except Exception as ex:
QMessageBox.critical(self, "Load Failed", str(ex))
def export_png(self):
fn, _ = QFileDialog.getSaveFileName(self, "Export PNG", "", "PNG (*.png)")
if not fn: return
from PyQt5.QtGui import QImage
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
img = QImage(int(united.width()+160), int(united.height()+160), QImage.Format_ARGB32)
img.fill(Qt.white)
painter = QPainter(img)
painter.translate(-united.x()+80, -united.y()+80)
self.scene.render(painter, QRectF(img.rect()), united)
painter.end()
img.save(fn)
self.status.showMessage(f"Exported PNG: {fn}", 3500)
def export_svg(self):
fn, _ = QFileDialog.getSaveFileName(self, "Export SVG", "", "SVG (*.svg)")
if not fn: return
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
gen = QSvgGenerator()
gen.setFileName(fn)
gen.setSize(united.size().toSize())
gen.setViewBox(united.toRect())
painter = QPainter(gen)
self.scene.render(painter, united, united)
painter.end()
self.status.showMessage(f"Exported SVG: {fn}", 3500)
def export_pdf(self):
fn, _ = QFileDialog.getSaveFileName(self, "Export PDF", "", "PDF (*.pdf)")
if not fn: return
from PyQt5.QtPrintSupport import QPrinter
items = self.scene.items()
if not items: return
rects = [i.mapToScene(i.boundingRect()).boundingRect() for i in items]
united = QRectF()
for r in rects: united = r if united.isNull() else united.united(r)
printer = QPrinter(QPrinter.HighResolution)
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName(fn)
painter = QPainter(printer)
view = self.view.rect()
self.scene.render(painter)
painter.end()
self.status.showMessage(f"Exported PDF: {fn}", 3500)
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
1.0.0
Updated 2025-09-07
Size ~36 MB
System requirements
- Windows 10 or later
- ~36 MB free space
- No .NET required (bundled)