A live LaTeX laboratory notebook for working scientists
Part of the Pharos Project · José Labarca Baeza · Universidad Técnica Federico Santa María · Valparaíso
About · Gallery · Features · Architecture · Shortcuts · Install · Tutorials · CLI · Security · Cite · Español
lablog is a research-grade laboratory notebook that lives where the experiment happens. It pairs a structural LaTeX editor with live preview, executable cells, parameterised diagrams, voice dictation, and an immutable event-sourced history so that the record of an investigation can be reconstructed exactly as it unfolded.
Unlike a typesetting tool used after the experiment, lablog is designed to run while the work is in progress — instrument open, notes incomplete, values still moving. The guiding premise is simple: the act of writing the paper should not be separated from the act of producing the data.
| Tool | When you typically use it |
|---|---|
| Overleaf | After the experiment, when the manuscript is prepared. |
| TeXstudio / TeXmacs | Traditional desktop LaTeX authoring. |
| Jupyter / JupyterLab | Computational notebooks; prose is secondary. |
| lablog | During the experiment: dictate, execute, parameterise diagrams, and preserve as it happens. |
- Local-first. Default bind address is loopback. Your notes stay under
LABLOG_DATA_DIR. - Event sourcing, not silent mutation. Writes append immutable events; state is a pure projection.
- Approximate preview + faithful PDF. KaTeX is for speed; Tectonic is for truth.
- Optional weight. Core install is lean; voice, desktop, and PySpice are extras.
- Security as correctness. Path traversal, shell-escape, size limits, and OCC are invariants, not afterthoughts.
| Item | Value |
|---|---|
| Distribution | jose-labarca-lablog on PyPI |
| Current release | v0.3.1 (notes) |
| Licence | MIT |
| Primary language (engine) | Python 3.11+ |
| Primary language (UI) | TypeScript / React 19 |
| Maintainer | José Labarca Baeza |
Real captures from a running instance (Vite + FastAPI, dark theme, v0.3.x). Regeneration script: scripts/capture_ui_screenshots.mjs.
Figure 1. Main shell: project groups, structural LaTeX editor, live preview (\section, equation, % lablog-param).
Figure 2. Diagram workbench: circuit / control / optics presets with Insert, +Sim, and SPICE badges.
Figure 3. Parameter panel: dial values, dual highlight targets, re-apply diagram / re-apply + sim.
Figure 4. Laboratory mode: dense cell layout, Python source, run / reorder / delete controls.
Figure 5. Preferencias: editor font, palettes, accent colour, and editable global chords (mod+…).
Figure 6. Full preferences surface (density, motion, laboratory layout, import / export JSON).
Figure 7. Document with embedded \begin{python} cell and parameters open for re-apply.
Figure 8. Identity kit and layered architecture illustration.
| Module | Capability |
|---|---|
| Structural LaTeX renderer | Sections, emphasis, lists, hyperlinks, and inline mathematics within prose. KaTeX for display environments (align, equation, gather, cases, pmatrix) with automatic equation numbering. |
| Editor | Line gutter, parameter overlay, debounced auto-save with optimistic concurrency (OCC), find & replace, undo / redo that survives programmatic inserts, smart delimiter wrapping, snippets and symbols at the cursor. |
| Voice dictation | Browser SpeechRecognition with intent parsing, plus optional local Whisper ([voice] extra). Session timeout prevents hung recognisers. |
| Executable cells | \begin{python}...\end{python} (and lab-mode cells) run on a real Jupyter kernel. Stdout, results, and figures return into the document; kernels are interrupted on timeout. |
| Vault | Attach images, CSV, PDF, DOCX, audio, scripts; preview in place. Atomic metadata; basename-only filenames; 100 MB ceiling; time-locked deletion. |
| Event-sourced history | Every edit, execution, and attachment is append-only JSONL. Time-travel UI scrubs and restores any event index with version-aware OCC on the client. |
| Diagram workbench | Twelve parameterised presets (circuits, control, mechanics, optics, Feynman). Re-apply without {{placeholders}} via % lablog-param. Dual highlight (editor line + Circuitikz colour). Optional PySpice cells with numpy fallback. |
| Personalisation | Density, editor font, Nord palette, reduced motion, profiles (Laboratory / Paper / Teaching), exportable preferences JSON, configurable keyboard shortcuts. |
| Export | .tex, plain text, HTML, PDF, DOCX (pandoc), Jupyter .ipynb, static site for GitHub Pages, Canva-ready HTML. Titles LaTeX-escaped. |
| Desktop | Native window via pywebview ([desktop]); portable PyInstaller bundle for offline distribution. |
%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor':'#1C1C1E', 'primaryTextColor':'#F2F2F7', 'primaryBorderColor':'#48484A', 'lineColor':'#A1A1A6', 'secondaryColor':'#48484A', 'tertiaryColor':'#1C1C1E' }}}%%
flowchart TB
subgraph Client["Client (browser or pywebview)"]
UI["React 19 · Vite 8 · Zustand"]
ED["LaTeX editor · OCC autosave"]
PV["KaTeX preview · PDF panel"]
LB["Lab canvas · cells"]
DG["Diagrams · parameters"]
end
subgraph Engine["Engine (FastAPI · localhost)"]
API["REST /api/v1"]
CMD["commands.py"]
PRJ["projector · projections"]
AST["latex_ast · serialize"]
KER["code_engine · Jupyter"]
PDF["pdf_engine · Tectonic"]
VLT["vault"]
DIA["diagrams · expand · pyspice"]
end
subgraph Disk["LABLOG_DATA_DIR"]
EV["events/*.jsonl"]
FIG["figures/"]
VAULT["vault/"]
BIN["bin/tectonic"]
SITE["site/ export"]
end
UI --> API
ED --> API
LB --> API
DG --> API
API --> CMD
CMD --> EV
PRJ --> EV
KER --> FIG
VLT --> VAULT
PDF --> BIN
API --> SITE
%%{init: { 'theme': 'base', 'themeVariables': { 'primaryColor':'#1C1C1E', 'primaryTextColor':'#F2F2F7', 'primaryBorderColor':'#48484A', 'lineColor':'#A1A1A6' }}}%%
sequenceDiagram
participant UI as UI / CLI
participant API as FastAPI
participant ES as EventStore
participant P as Projector
UI->>API: PUT /pages/{id} raw + version
API->>ES: append(document_replaced, expected_version)
alt version mismatch
ES-->>API: VersionConflictError
API-->>UI: 409 VERSION_CONFLICT + current
else ok
ES-->>API: fsync JSONL line
API->>P: project(events)
P-->>API: PageDetail
API-->>UI: latex, ast, version, project_id
end
Rules enforced in code and CONTRIBUTING:
- Never mutate the projected AST from the API; append an event and re-project.
- Paths only through
src/lablog/config.py. - Wire shapes mirrored in
ui/src/lib/api.ts. - Network I/O kept out of leaf components when possible (Zustand + hooks).
- Diagrams: presets in
catalog.py, clamp/expand inexpand.py, SPICE optional inpyspice_sim.py.
Module map (source tree)
| Path | Responsibility |
|---|---|
src/lablog/api.py |
HTTP surface, OCC on replace, vault, diagrams, export. |
src/lablog/event_store.py |
Append-only JSONL; per-page lock; conditional append (expected_version). |
src/lablog/events.py |
Event types and constructors. |
src/lablog/projector.py |
Pure fold of events into page state. |
src/lablog/projections.py |
Read models: detail, summary, history, cells. |
src/lablog/commands.py |
Domain commands (create, replace, cells, restore). |
src/lablog/latex_ast.py |
Parse / serialise document tree. |
src/lablog/code_engine.py |
Jupyter kernel with timeout interrupt. |
src/lablog/vault.py |
Attachments, atomic meta, deletion schedule. |
src/lablog/exporter.py |
Static site and notebook export. |
src/lablog/pdf_engine.py |
Tectonic install, warm, compile, error mapping. |
src/lablog/diagrams/ |
Presets, expand, highlight, PySpice / numpy. |
src/lablog/cli.py |
lablog entry point. |
ui/src/stores/app-store.ts |
Client state, preferences, flush hooks. |
ui/src/hooks/use-page-update.ts |
Debounced autosave, serialised PUT, 409 retry. |
ui/src/components/editor/latex-editor.tsx |
Editor surface. |
ui/src/components/lab/lab-canvas.tsx |
Lab mode; dirty-cell flush on exit. |
| Layer | Technologies |
|---|---|
| Engine | Python 3.11+, FastAPI, Pydantic v2, Jupyter Client, optional faster-whisper / PySpice |
| Persistence | JSONL event log, atomic renames for vault meta, deterministic projection |
| Interface | React 19, TypeScript, Vite 8, Tailwind CSS v4, Zustand, shadcn/ui, Radix |
| Mathematics | KaTeX (preview); Tectonic / XeTeX (PDF) |
| Tooling | uv, npm, Ruff, Mypy (strict), oxlint, pytest (≥80% cov), Vitest, Playwright (smoke), pre-commit, GitHub Actions |
pip install -U jose-labarca-lablog
lablog serveOpen the UI served by the engine (bundled wheel includes compiled ui/dist), or point a
dev front-end at the API.
| Extra | Install | Purpose |
|---|---|---|
| Desktop | pip install "jose-labarca-lablog[desktop]" |
Native window (lablog app) |
| Offline voice | pip install "jose-labarca-lablog[voice]" |
Local Whisper (large download) |
| SPICE | pip install "jose-labarca-lablog[pyspice]" |
PySpice cells (needs ngspice on PATH) |
| Dev | pip install "jose-labarca-lablog[dev]" |
pytest, ruff, mypy, bandit, pre-commit |
Prerequisites. Python ≥ 3.11, Node 22, uv,
npm. Optional:pandoc(+ TeX) for DOCX/PDF via pandoc; Tectonic is managed by lablog for in-app PDF.
git clone https://github.com/kegouro/lablog.git
cd lablog
uv sync --extra dev
source .venv/bin/activate
cp .env.example .env
cd ui && npm install && cd ..# Optional extras
uv sync --extra desktop
uv sync --extra voice
uv sync --extra pyspice# Terminal A — API
source .venv/bin/activate
uvicorn lablog.api:app --host 127.0.0.1 --port 8000 --reload
# Terminal B — UI
cd ui && npm run dev| Surface | URL |
|---|---|
| UI (Vite) | http://127.0.0.1:5173 |
| API | http://127.0.0.1:8000/api/v1 |
| Health | http://127.0.0.1:8000/api/v1/health |
| OpenAPI | http://127.0.0.1:8000/docs |
cd ui && npm run build && cd ..
uvicorn lablog.api:app --host 127.0.0.1 --port 8000
# or
lablog serve --host 127.0.0.1 --port 8000uv sync --extra desktop
cd ui && npm run build && cd ..
lablog appcurl -s http://127.0.0.1:8000/api/v1/health | python -m json.toolsource .venv/bin/activate
# Create an empty page
lablog create-page --title "RC lab session"
# Or seed from a physics template
lablog new --title "Optics notes" --template article_physics
lablog list-pages
lablog render <page_id> # print projected LaTeX
lablog events <page_id> # inspect JSONL history- Start API + UI (Quick start).
- Create a page from the sidebar.
- Open Diagrams → RC serie — carga.
- Insert diagram (or insert + simulation cell).
- Open Parameters: adjust
R,C,V0. - Re-apply; confirm
% lablog-paramcomments and TikZ update. - Focus a parameter: dual highlight (gutter line + Circuitikz
color=). - Export Notebook Jupyter (.ipynb) or Compilar PDF.
# Equivalent expand from CLI
lablog diagrams list
lablog diagrams expand rc_series_charge --jsonIn the editor (or Lab mode):
\begin{python}[label=demo]
import numpy as np
import matplotlib.pyplot as plt
t = np.linspace(0, 5, 200)
plt.plot(t, np.exp(-t))
plt.xlabel("t")
plt.ylabel("e^{-t}")
\end{python}Run the cell from the Cells panel or Lab canvas. Output and figures are stored under
LABLOG_DATA_DIR/figures/<page_id>/ and projected into the AST.
- Edit the page several times (autosave every ~300 ms of idle).
- Open history / time-travel; scrub the event index; restore a past version.
- Concurrent edits: client sends
version; on conflict the API returns409witherror_code: VERSION_CONFLICTandcurrent. The UI retries once or requeues the draft.
source .venv/bin/activate
# Prefer versioning data inside the repo for public notes:
# LABLOG_DATA_DIR=./data
python - <<'PY'
from lablog.exporter import export_site
print(export_site())
PYEnable Settings → Pages → GitHub Actions on a fork; .github/workflows/pages.yml
publishes on push to main.
lablog {create-page,new,list-pages,append-text,render,events,serve,app,diagrams}
| Command | Purpose |
|---|---|
create-page |
Create a page (title / project). |
new |
Create page; optional --template. |
list-pages |
List page summaries. |
append-text |
Append text event. |
render |
Project and print LaTeX. |
events |
Dump event log for a page. |
serve |
Run uvicorn-backed API (serves UI if built). |
app |
Desktop shell ([desktop]). |
diagrams list |
Catalogue presets. |
diagrams expand <id> |
Expand TikZ (+ params) to stdout / JSON. |
Examples:
lablog serve --host 127.0.0.1 --port 8000
lablog diagrams list
lablog diagrams expand thin_lens --set f=0.1 --set do=0.3Base path: /api/v1. Interactive schema: /docs (Swagger) when the server is running.
Core resources (summary)
| Method | Path | Notes |
|---|---|---|
| GET | /health |
Liveness / engine flags. |
| GET/POST | /pages |
List / create (title, project_id bounded). |
| GET/PUT/PATCH/DELETE | /pages/{id} |
Detail (incl. project_id, updated_at, version); raw replace with OCC; metadata; soft-delete. |
| POST | /pages/{id}/text, /math, /voice, /replace |
Domain inserts / replace. |
| GET | /pages/{id}/history, /at/{i}, POST /restore/{i} |
Time travel. |
| POST/GET | /pages/{id}/cells... |
Insert, update (returns version), execute, move (returns version), figure. |
| GET/POST | /diagrams/presets... |
List, expand, simulate-source, apply. |
| GET/POST | /snippets..., /latex-symbols... |
Catalogues and favourites. |
| GET/POST | /vault... |
Upload, preview, download, delayed delete, purge. |
| GET/POST | /pdf/*, /pages/{id}/export/* |
Engine status, install, compile, multi-format export. |
| POST | /export |
Static site export. |
OCC contract (PUT raw / replace):
{
"detail": {
"error_code": "VERSION_CONFLICT",
"message": "La página cambió en otro cliente; recarga e inténtalo de nuevo",
"expected": 5,
"current": 6
}
}Conditional append is atomic under the per-page file lock (EventStore.append(..., expected_version=)).
preset_id |
Title | Category |
|---|---|---|
voltage_divider |
Divisor de tensión | circuitos |
noninverting_opamp |
Op-amp no inversor | circuitos |
wheatstone |
Puente de Wheatstone (DC) | circuitos |
rc_lowpass |
RC pasa-bajos | circuitos |
rc_series_charge |
RC serie — carga | circuitos |
half_wave_rectifier |
Rectificador media onda + C | circuitos |
rlc_series_step |
RLC serie — escalón | circuitos |
second_order_step |
2º orden — respuesta al escalón | control |
pi_controller |
PI + planta 1er orden | control |
mass_spring_damper |
Masa-resorte-amortiguador | mecanica |
thin_lens |
Lente delgada | optica |
qed_moller |
QED e⁻e⁻ (árbol) | particulas |
Presets with PySpice support degrade to pedagogical numpy code when PySpice / ngspice are absent. Headers in generated LaTeX:
% lablog-diagram: preset=rc_series_charge version=1
% lablog-param: C=1e-06
% lablog-param: R=1000
% lablog-highlight: Rmod is ⌘ on macOS and Ctrl on Windows / Linux. Chords are editable under
Preferencias → Atajos and exported with preferences JSON.
| Action | Default chord | macOS display | Notes |
|---|---|---|---|
| Command palette | mod+k |
⌘K | Pages, panels, profiles |
| Save (flush autosave) | mod+s |
⌘S | Forces pending PUT |
| Toggle diagrams panel | mod+shift+d |
⌘⇧D | Sidebar tool |
| Toggle parameters panel | mod+shift+p |
⌘⇧P | Sliders / re-apply |
| Toggle cells panel | mod+shift+c |
⌘⇧C | Executable cells |
| Toggle laboratory mode | mod+shift+l |
⌘⇧L | Flushes dirty cells on exit |
| New page | mod+n |
⌘N | Creates via API |
Source of truth: ui/src/lib/shortcuts.ts (DEFAULT_SHORTCUTS).
| Action | Shortcut |
|---|---|
| Find / replace | Ctrl+F / Ctrl+H |
| Next / previous match | Enter / Shift+Enter |
| Undo / redo | Ctrl+Z / Ctrl+Y |
| Bold · Italic · Inline math | Ctrl+B · I · E |
| Indent selection | Tab |
| Wrap selection in delimiters | type { ( [ $ on a selection |
Editor history survives programmatic insertions (symbols, snippets, voice), where browser-native undo usually breaks.
Preferences (density, font, palette, shortcuts, profiles) live in localStorage and can
be exported / imported as JSON from Settings.
Lab mode is a dense layout for cell-first work (Python / markdown / LaTeX cells).
- Source is local until blur or explicit save; leaving lab mode flushes dirty cells
(toolbar, shortcuts, command palette, settings, and unmount) and resynchronises
activeVersionviaGET /pages/{id}. - Keyboard profiles include a Laboratory preset (compact density, mono font, lab flag).
Live preview is approximate (KaTeX + HTML). Faithful output uses Tectonic (self-contained XeTeX).
- Compilar PDF in the preview header; preview labelled Aproximada.
- Python cells render as code + output + figures (
fancyvrb,\includegraphics). - Async compile, hard timeout (
504on runaway); cache by document hash. - Errors map to source via
% lablog-srcmarkers (Celda N · línea M). - No
--shell-escape. LaTeX cannot run OS commands. - Managed binary: checksum-pinned under
LABLOG_DATA_DIR/bin/; never “latest” at runtime.
lablog is local and single-user. Do not expose the compile endpoint publicly without rate limiting and isolation.
| Property | Behaviour |
|---|---|
| Storage | LABLOG_DATA_DIR/vault/ + atomic meta.json |
| Filenames | Basename only (blocks ../) |
| Size | Max 100 MB → HTTP 413 |
| Lifecycle | Soft delete schedule; force delete with confirmation phrase; purge expired |
| Format | How | Notes |
|---|---|---|
.tex |
Export menu | Full document serialisation |
.txt |
Export menu | Plain reduction |
.pdf |
In-app Tectonic or pandoc path | Prefer in-app for cells/figures |
.docx |
pandoc | Requires pandoc + TeX for math-heavy docs |
.ipynb |
Export menu | Jupyter notebook for cells + markdown |
| Static site | Export / CI | GitHub Pages |
| Canva HTML | Export menu | Presentation-oriented HTML |
See .env.example.
| Variable | Default | Purpose |
|---|---|---|
LABLOG_DATA_DIR |
~/.lablog |
Events, vault, figures, managed binaries |
LABLOG_HOST |
127.0.0.1 |
API bind host (invalid port → 8000) |
LABLOG_PORT |
8000 |
API bind port (1–65535) |
LABLOG_CORS_ORIGINS |
Vite dev origins | Comma-separated |
LABLOG_CORS_CREDENTIALS |
true |
CORS credentials |
LABLOG_SITE_DIR |
${data_dir}/site |
Static export root |
Never commit secrets. Treat LABLOG_DATA_DIR as personal research data.
$LABLOG_DATA_DIR/
├── events/ # one JSONL stream per page_id
│ └── <uuid>.jsonl
├── vault/ # attachments + meta.json
├── figures/ # per-page cell figures
│ └── <page_id>/
├── bin/ # managed tectonic (optional)
└── site/ # last static export (if configured)
Page identifiers are constrained to a safe alphabet ([A-Za-z0-9_-]{1,128}) so
filesystem paths cannot escape the events root.
| Invariant | Mechanism |
|---|---|
| Page IDs cannot traverse directories | Regex validation at EventStore |
| Upload names cannot escape vault | Basename only |
| Upload size bounded | 100 MB → 413 |
| User code cannot hang the kernel | Deadline + interrupt_kernel() |
| Titles cannot inject LaTeX | Meta-character escape on export |
| Figures cannot leave figure root | Resolve + containment check |
| Vault meta concurrent-safe | Tempfile + atomic rename |
| Corrupt events do not brick a page | Skip bad JSONL lines |
| Soft-deleted pages reject writes | 409 on mutate |
| OCC on document replace | Atomic expected_version under lock |
| Title / project_id length bounded | Pydantic max_length (500 / 128) |
| Tectonic isolation | No shell-escape |
Report vulnerabilities privately via GitHub Security Advisories or the maintainer profile; see SECURITY.md.
# Engine
source .venv/bin/activate
pytest -q
ruff check src tests
mypy -p lablog
bandit -r src/lablog -ll
# UI
cd ui
npx tsc --noEmit
npm run lint
npm test -- --run
npm run build
# Optional e2e
npm run test:e2e:install && npm run test:e2e| Gate | Threshold / tool |
|---|---|
| Backend coverage | ≥ 80% (pytest-cov) |
| Typecheck | Mypy strict (package), tsc --noEmit |
| Lint | Ruff, oxlint / ESLint pipeline |
| Pre-commit | whitespace, ruff, mypy, tsc, oxlint |
| CI | .github/workflows/ci.yml — backend + frontend |
Interactive features (editor, cells, voice) remain local. The static exporter produces a KaTeX-rendered site for sharing.
source .venv/bin/activate
uv run python - <<'PY'
from lablog.exporter import export_site
print(export_site())
PY- Repository Settings → Pages → Source: GitHub Actions
- Push to
main - Workflow
.github/workflows/pages.ymldeploys
Live instance: kegouro.github.io/lablog
./scripts/package_desktop.sh
# → dist/lablog/ (zip and distribute)Spec: lablog.spec. Voice model excluded by default. Treat the bundle as
a verified starting point for portable builds, not a universal one-click installer.
| Milestone | Status |
|---|---|
| Event-sourced engine + projection | Done |
| Voice → intent → LaTeX | Done |
| Structural renderer | Done |
| Executable cells + timeout | Done |
| Vault + delayed delete | Done |
| Editor F&R, undo/redo, cursor insert | Done |
| Static export + Pages | Done |
| Desktop (pywebview) | Done |
| Time-travel restore | Done |
| In-app PDF + error mapping | Done |
| Autocomplete + templates CLI | Done |
| Diagram presets + re-apply + dual highlight | Done (0.3.0) |
| Jupyter export + optional PySpice | Done (0.3.0) |
| UI profiles + shortcuts | Done (0.3.0) |
| OCC harden + lab dirty flush | Done (post-0.3.0) |
| BibTeX / citeproc | Planned |
| Section / equation cross-refs | Planned |
| P2P collab / multi-device sync | Exploratory |
If lablog supports work that leads to a publication, please cite:
@software{labarca_lablog,
author = {Labarca Baeza, José},
title = {{lablog}: a live LaTeX laboratory notebook for working scientists},
year = {2026},
version = {0.3.1},
url = {https://github.com/kegouro/lablog},
note = {Part of the Pharos Project}
}Machine-readable metadata: CITATION.cff.
See CONTRIBUTING.md. Security: SECURITY.md. Conduct: CODE_OF_CONDUCT.md. Changelog: CHANGELOG.md.
Preferred contribution shape: small PR, tests for the behaviour you change, no drive-by refactors. Architecture rules in CONTRIBUTING are binding.
Released under the MIT License.
lablog is part of the Pharos Project — infrastructure for scientific and educational work that should feel local, honest, and reconstructible. Identity and graphics by José Labarca Baeza. Original idea conceived with Vicente Muñoz Tolosa.
