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.

Download for Windows (.exe)

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_())
          
Version 1.0.0 Updated Sep 07, 2025 Size ~36 MB Free • Offline • No tracking

Why you’ll like it

Drag & drop shapes

Rectangles, ellipses, diamonds, and notes with rounded corners.

Smart connectors

Magnetic ports, tidy curves, auto-reroute when you move nodes.

Inline text editing

Double-click to edit labels. Undo/redo for every change.

Grid & snap

Clean alignment without fiddling. Pan/zoom with smooth rendering.

Export anywhere

PNG for slides, SVG for devs, PDF for print—one click.

Private by design

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

1
Install

Download and run. No admin required—uses your user profile.

2
Draw

Add shapes, drag to connect ports, and label your steps inline.

3
Export & share

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

Installer (.exe)

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_())
              
Version 1.0.0 Updated 2025-09-07 Size ~36 MB

System requirements

  • Windows 10 or later
  • ~36 MB free space
  • No .NET required (bundled)
Prefer the tools bundle? Explore more free utilities on the home page.

← Back to Home