---
title: "Rebuilding a visual TikZ editor around a TikZ-shaped document model"
description: "Why I rebuilt TikZpen from scratch: a data model shaped like TikZ, one renderer shared by the canvas and the export, and a compile-based check that proves the exported code matches the drawing."
date: "2026-10-12T10:23:05.743Z"
author: "Zsombor Rózsa"
tags: ["typescript", "react", "latex", "architecture"]
canonical: "https://codescrambler.eu/blog/rebuilding-tikzpen-around-a-tikz-shaped-model"
lang: "en"
---

TikZpen is a free browser editor where you draw shapes, text and Bézier paths on a canvas and get ready-to-paste LaTeX TikZ code in real time. In September 2026 I rebuilt it from scratch. This is why, and what I learned about building a tool where correctness is the whole point.

![TikZpen with a few shapes drawn and the generated TikZ code in the side panel.](/images/blog/tikzpen/editor-with-code.webp)

## The old model fought the output

The first version worked, but its internal model was a generic canvas scene graph, and TikZ is not a canvas. Every feature meant translating between two ideas of what a drawing is, and the translation was where the bugs lived: a shape could look right on screen and export subtly different TikZ.

```tikz
% libraries: arrows.meta, positioning, decorations.pathmorphing
\begin{tikzpicture}[font=\sffamily\small, >={Stealth}, node distance=12mm,
  box/.style={draw, rounded corners, minimum height=9mm, minimum width=2.5cm, align=center}]
  \node[box, fill=red!10] (canvas) {Canvas\\scene graph};
  \node[box, right=36mm of canvas, fill=red!10] (tikz) {TikZ};
  \draw[->, thick, red!60!black, decorate, decoration={snake, amplitude=0.6mm, segment length=4mm}] (canvas) -- node[above=2mm, red!60!black] {lossy translation} (tikz);
  \node[below=8mm of canvas, align=center, font=\sffamily\footnotesize, text=red!60!black] {two models of "a drawing"};
\end{tikzpicture}
```

## Make the model TikZ-shaped

In the rebuild the native document format *is* TikZ-shaped: layers, nodes, paths and styles map straight onto what TikZ can say. Export stops being a conversion and becomes a per-node emit. The rule I wrote down for myself: **if a field doesn't correspond to something TikZ can express, it doesn't belong in the model.**

```tikz
% libraries: arrows.meta, positioning
\begin{tikzpicture}[font=\sffamily\small, >={Stealth}, node distance=10mm,
  box/.style={draw, rounded corners, minimum height=9mm, minimum width=2.6cm, align=center},
  doc/.style={box, fill=orange!25, minimum width=3.4cm, minimum height=12mm}]
  \node[doc] (doc) {Document\\layers, nodes, paths, styles};
  \node[box, above right=2mm and 20mm of doc] (canvas) {Live canvas};
  \node[box, right=20mm of doc] (svg) {SVG export};
  \node[box, below right=2mm and 20mm of doc] (tikz) {TikZ export};
  \node[box, below=10mm of doc] (imp) {Import\\TikZ and SVG};
  \draw[->] (doc) -- node[above, sloped, font=\sffamily\scriptsize] {one renderer} (canvas);
  \draw[->] (doc) -- node[above, font=\sffamily\scriptsize] {same renderer} (svg);
  \draw[->] (doc) -- node[below, sloped, font=\sffamily\scriptsize] {per-node emit} (tikz);
  \draw[->] (imp) -- (doc);
\end{tikzpicture}
```

## One renderer, no drift

The live canvas and the SVG export use the *same* renderer: a single function turns a document into a React SVG tree. If the viewport and the export can't disagree about what a shape looks like, a whole class of "looks different once exported" bugs simply can't exist.

## Proving it with a fidelity check

"The export looks right" isn't a test. So the exported TikZ is sent to a compile service and the compiled result is compared with the editor's own rendering of the document, a check that the TikZ really reproduces the drawing one to one. I run the same harness in development, and it exposed real renderer bugs that looking at the screen had missed.

```tikz
% libraries: arrows.meta, positioning
\begin{tikzpicture}[font=\sffamily\small, >={Stealth}, node distance=9mm,
  box/.style={draw, rounded corners, minimum height=9mm, minimum width=2.4cm, align=center}]
  \node[box] (doc) {Document};
  \node[box, right=14mm of doc] (tikz) {Exported\\TikZ};
  \node[box, right=14mm of tikz] (comp) {LaTeX compile\\service};
  \node[box, right=14mm of comp] (img) {Compiled\\image};
  \node[box, below=12mm of doc] (ren) {Editor's own\\rendering};
  \node[box, fill=orange!25, below=12mm of img] (cmp) {Compare};
  \draw[->] (doc) -- (tikz); \draw[->] (tikz) -- (comp); \draw[->] (comp) -- (img);
  \draw[->] (doc) -- (ren);
  \draw[->] (img) -- (cmp);
  \draw[->] (ren) -- (cmp);
  \node[right=3mm of cmp, font=\sffamily\footnotesize, align=left] {match = export is faithful\\mismatch = a bug to fix};
\end{tikzpicture}
```

Around that check:

- **Golden fixtures.** Export output is pinned with `.tikz` and `.svg` fixtures, and the pure logic (model, geometry, commands, export, import) is covered by Jest tests.
- **Undo and redo as commands.** Every edit is an invertible command on a bounded history, which keeps undo reliable across multi-select, groups and path editing.
- **Import as well as export.** TikZ and SVG importers let people bring existing work in, with warnings for anything that can't be represented rather than silently dropping it.

## Product details that mattered

Autosave to the browser for everyone, optional cloud documents with version history for signed-in users, snapping, node editing, layers and groups, and a mobile layout that stacks the panels. The old editor is still reachable (noindexed) for old bookmarks, but all new work goes into the rebuild.

## Stack

TypeScript, React, Next.js, a Zustand store, IndexedDB and Firestore for persistence, a containerised LaTeX compile service, Jest.

## Takeaway

When correctness is the product, shape the data model after the output format, share one renderer between everything that draws, and let an automated check tell you when you're wrong. You can try the editor at [TikZpen](/tools/tikz-editor), and if you're new to TikZ, [this tutorial](/blog/tikz-tutorial-draw-shapes-arrows-and-paths) covers the language itself.