Live preview¶
rules_latex ships latex_live for the
"edit-the-source-watch-the-PDF-update" workflow. It watches the
document's transitive sources via LatexInfo and rebuilds through Bazel by
default, pushing the result to a localhost HTTP page rendered with PDF.js. An
opt-in fast path can replay content-only compiles directly when lower latency
matters.
latex_serve was removed in v0.6.0
Earlier releases also exposed a latex_serve rule that
opened the document in the system PDF viewer (macOS
Preview, Linux Evince/Okular, Adobe Reader, etc.) and
relied on the viewer's own auto-reload-on-disk-change
behaviour to refresh the preview after each rebuild. That
contract eroded — macOS Preview's auto-reload became
unreliable after the Sonoma sandbox changes, Adobe never
watched the file on macOS — and v0.6 removed the rule
rather than ship a viewer-specific workaround. If you
prefer a native PDF viewer, point a reload-aware one (Skim,
Sioyek, Zathura, PDF Expert) at
bazel-bin/.../<doc>.pdf directly and run bazel build after edits
to update it; a one-shot build does not watch for saves.
latex_live — in-browser preview¶
Declare a latex_live target alongside your latex_document:
load("@rules_latex//latex:defs.bzl", "latex_document", "latex_live")
latex_document(
name = "cv",
main = "cv.tex",
srcs = ["cv.tex"],
synctex = True, # PDF clicks copy <file>:<line> to clipboard
)
latex_live(
name = "cv_live",
document = ":cv",
)
Open the URL in your browser. The page:
- Renders the PDF with PDF.js (vendored, no CDN dependency).
- Receives changed PDF chunks over WebSocket, with Server-Sent Events as a transparent fallback.
- Preserves scroll position across reloads.
- When
synctex = Trueis set on the document, clicking anywhere in the rendered PDF resolves to a sourcefile:line, displays it in the footer bar, and copies it to the clipboard (reverse-sync lookup — the browser can't drive your editor to that location; you paste the location into whatever opens files for you). The footer text is itself clickable to recopy. - Editors can push the other direction (forward-sync) by
POSTing to
/sync/forward; the browser scrolls the page into view and flashes a yellow overlay at the matching PDF location. This direction does jump because the editor — not the browser — is the one driving the navigation. See SyncTeX forward-sync below.
How fast is the loop?¶
Distinguish compiler/build time from source-save to visible repaint. The latter also includes filesystem polling (80 ms by default), debounce (250 ms), transport, and PDF.js rendering. Cached no-change builds are a different workload again. A first online prime can take tens of seconds; live preview then reuses its persistent cache for ordinary edits.
In the September 2026 combined-change experiment on a shared Apple Silicon Mac, median save-to-repaint times with warmed cache snapshots ranged from 1.45 s for one page to 3.83 s for a Biber thesis on the default path. The opt-in fast path measured 1.15–1.64 s for the small, 100-page, and image-heavy fixtures. These are workload-specific observations, not latency guarantees or proof of an overall speedup. See the performance record for the comparison, accepted costs, and measurement limitations.
For larger documents, the TeX compile often dominates. The browser keeps canvas memory bounded to pages near the viewport, reuses painted pages only when the PDF bytes are identical, and limits concurrent raster, text-layer, and search-index work, so reload cost does not grow linearly with every page in the document.
Opt-in fast content rebuilds¶
Set serve_fast = True on latex_live to skip Bazel's command startup,
analysis, and sandbox setup for ordinary edits to already-declared content:
The first build still uses bazel build. The watcher also falls back to Bazel
when inputs change structurally or the direct compile reports a missing cached
resource. The direct path runs outside Bazel's sandbox, but it stages only the
document's declared inputs and replays the same pinned compile tool. Keep the
default False when exact command-path parity with CI matters more than
avoiding that overhead; the benefit varies by document and machine.
Local access and security¶
Use the printed URL, http://127.0.0.1:<port>/. Requests must carry that exact
Host, and a supplied Origin must match it. WebSocket connections must include
the matching Origin. The server rejects localhost aliases, foreign browser
origins, and proxy hostnames with 403, even if they resolve to loopback.
The checks protect against cross-origin browser access and DNS rebinding;
they do not authenticate local processes or make preview a public server.
Editor integrations using curl can omit Origin but must use the printed
host and port. Do not expose the service through a public reverse proxy.
HTTP connections and WebSocket/SSE clients are bounded, slow clients have
timeouts, and sync request bodies are limited to 64 KiB. These bounds can
reject excess requests rather than allowing unbounded server resource use.
What gets watched?¶
The watcher monitors every .tex, .bib, image, and other file in
the document's srcs plus transitively via deps. Edits to the
toolchain binary or cache configuration require restarting latex_live;
the new launcher selects a separate persistent cache. A re-prime keeps
older extracted generations so an in-flight compile can finish safely.
After stopping all preview processes, remove .cache/rules_latex/ in your
workspace if you want to reclaim that storage.
External-repo files (e.g. from a latex_library published in another
Bazel module) are not watched. Edit those and re-run bazel run
//:cv_live to pick up the change.
SyncTeX forward-sync¶
When latex_document(synctex = True) is set, latex_live
exposes a POST /sync/forward endpoint that maps a source
(file, line) tuple to a PDF location and flashes a highlight in
every open browser tab. The complement to the click-on-PDF
reverse-sync lookup the same documents already support — except
this direction does jump (the editor, not the browser, drives
the navigation).
The endpoint is the integration point — your editor (or a small CLI
shim) is responsible for invoking it. The minimum viable wrapper is
two lines of curl:
# Jump every connected browser to cv.tex line 42.
curl -sf -X POST http://127.0.0.1:8765/sync/forward \
-H "Content-Type: application/json" \
-d '{"file":"cv.tex","line":42}' | jq .
Successful response:
The HTTP response is for the caller's logging; the actual UX (scroll + flash) happens in the browser tab over the active WebSocket or SSE transport.
Editor integrations¶
Any editor that can shell out on save can wire this up. Examples:
vim.api.nvim_create_autocmd("BufWritePost", {
pattern = "*.tex",
callback = function()
local line = vim.api.nvim_win_get_cursor(0)[1]
local file = vim.fn.expand("%:t")
vim.fn.jobstart({
"curl", "-sf", "-X", "POST",
"http://127.0.0.1:8765/sync/forward",
"-H", "Content-Type: application/json",
"-d", vim.fn.json_encode({file = file, line = line}),
}, {detach = true})
end,
})
Bind a keybinding to a task that runs curl with
${file} / ${lineNumber} substituted.
Hook LaTeX-after-write-hook to call
(shell-command (format "curl ... '{\"file\":\"%s\",\"line\":%d}'" ...)).
Response semantics¶
| Case | HTTP | Body |
|---|---|---|
| Line has output in the PDF | 200 | {"ok": true, "page": ..., "x": ..., "y": ..., "w": ..., "h": ...} |
| Line is recorded in SyncTeX but produced no boxes (e.g. a comment-only line) | 200 | {"ok": false, "error": "no PDF location found for that source line"} |
| File isn't in the document's input set | 200 | Same as above |
| SyncTeX file isn't produced yet (the build is still in progress) | 404 | {"ok": false, "error": "synctex file not produced yet"} |
Document was built without synctex = True |
404 | {"ok": false, "error": "synctex not enabled for this document"} |
When multiple boxes match the same source line (a line that produces
output on more than one PDF page — uncommon, but possible with
two-column layouts and \twocolumn breaks), the first match is
returned. That's the earliest occurrence in document order, which is
what most editors and users expect for a "jump-to" action.
Architecture¶
latex_live synthesises a small launcher script that:
- Polls the watched paths every 250 ms via
os.stat. - Runs
bazel build <document_label>on change, unlessserve_fast = Truecan safely replay an ordinary content compile. - Keeps a tiny HTTP server alive and pushes updates to connected browser tabs over WebSocket (see below) with an SSE fallback.
With the default serve_fast = False, this is the same bazel build
invocation as a normal build, so live mode follows the CI path exactly. The
opt-in fast path retains Bazel fallback for the first build, structural
changes, and missing resources. See
DESIGN.md §4.7
for the rationale.
WebSocket push transport¶
latex_live exposes /ws for live updates. After each
successful rebuild the server pushes the chunk manifest plus any
PDF chunks the connected client doesn't already have. The browser
applies them to its in-memory chunk cache and re-renders.
Wire format:
| Direction | Frame | Payload |
|---|---|---|
| server → client | text | {"type":"manifest","pdfSize":N,"pdfHash":"<sha256>","ranges":[{objectId,start,end,hash},...],"skeletonRanges":[[s,e],...]} |
| server → client | text | {"type":"build-failed","message":"…"} |
| server → client | text | {"type":"jump",...} (forward-sync from POST /sync/forward) |
| server → client | binary | <32-byte raw SHA-256><chunk bytes> (one per missing chunk) |
| client → server | text | {"type":"hello","have":[<hex sha256>,...]} (declares cache state on connect) |
Compared to the SSE path (reload event → /pdf-manifest fetch →
/chunk/<hash> fetch per missing chunk), the WS push saves the
two pull round-trips: the manifest arrives as part of the same
push burst as the chunk bytes.
Each manifest names an immutable PDF copy under the preview cache.
Skeleton byte ranges come from /pdf/<pdfHash>, so a rebuild cannot
mix two PDF generations in one render. On startup and after builds,
the server prunes each document's snapshot history to at most 8 PDFs / 128 MiB,
discarding history older than five minutes. Count and byte limits
take precedence over that grace window. The current manifest's PDF
is always retained, even when it exceeds 128 MiB; in that case no
history is kept. Publishing can temporarily require one additional
PDF's worth of disk space.
Already-open responses finish reading their original file even if
it is collected. Later requests for an expired generation return
410 Gone, and the browser reloads the current manifest. Republishing
an identical PDF refreshes its grace period. Copying and hashing a
10 MiB PDF added about 9 ms per successful rebuild in a local macOS
measurement; pruning only reads directory metadata, not PDF bodies.
When a render is superseded, its transport cancels skeleton requests and discards late responses. Shared chunk downloads remain usable by the new render. This prevents deliveries into retired PDF.js readers without disabling recovery from errors on the active render.
Fallback. If /ws can't connect (WS upgrade refused, no
ws_server module on the server side, proxy in the way, etc.)
the browser falls back transparently to the SSE flow at
/events. The user experience is unchanged; only the
build-to-render latency differs.