Skip to content

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",
)
bazel run //:cv_live
# serving live preview at http://127.0.0.1:8765/

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 = True is set on the document, clicking anywhere in the rendered PDF resolves to a source file: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:

latex_live(
    name = "cv_live",
    document = ":cv",
    serve_fast = True,
)

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:

{"ok": true, "page": 3, "x": 121.5, "y": 614.2, "w": 156.7, "h": 11.0}

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:

  1. Polls the watched paths every 250 ms via os.stat.
  2. Runs bazel build <document_label> on change, unless serve_fast = True can safely replay an ordinary content compile.
  3. 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.