feat: add local TeXLive as alternative LaTeX compilation backend

Tectonic's limited package support makes it unusable for academic work
with custom .sty/.cls files. This adds opt-in support for shelling out
to a locally installed TeXLive distribution.

Backend (latex.rs):
- compile_with_texlive(): multi-pass compilation (2-3 engine passes +
  optional bibtex/biber), with xdvipdfmx fallback for .xdv → .pdf
- find_texlive_binary(): resolves binaries via PATH, standard install
  locations, and macOS login-shell fallback for GUI apps
- detect_bib_tool(): scans .tex for biblatex/bibliography to choose
  biber vs bibtex
- texlive_env_path(): injects TeXLive bin dir into subprocess PATH so
  xelatex can find xdvipdfmx internally
- detect_texlive Tauri command: reports available engines and version
- compile_latex gains use_texlive parameter; error messages show which
  backend ran (e.g. "Compilation failed (TeXLive/xelatex)")
- All paths passed to TeX are relative to avoid tilde expansion in
  iCloud paths (com~apple~CloudDocs)

Frontend:
- New settings-store.ts with persisted compilerBackend preference
- Backend selector dropdown in PDF preview toolbar
- All compile call sites pass the setting through to the backend
- detectTexlive() and TexliveStatus for engine availability checks

Tectonic remains the default. TeXLive is opt-in via the toolbar
dropdown. All existing tests pass (152/152).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter-Bot 2026-03-27 00:02:38 +01:00
parent 0301f904bf
commit 51b6d6abec
7 changed files with 478 additions and 39 deletions

View file

@ -102,6 +102,102 @@ fn detect_tex_engine(content: &str) -> Option<TexEngine> {
None
}
#[derive(Debug, PartialEq)]
enum BibTool {
Biber,
BibTeX,
None,
}
/// Detect which bibliography tool is needed by scanning .tex content.
fn detect_bib_tool(content: &str) -> BibTool {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('%') {
continue;
}
if trimmed.contains("\\usepackage") && trimmed.contains("biblatex") {
return BibTool::Biber;
}
}
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('%') {
continue;
}
if trimmed.contains("\\bibliography{") || trimmed.contains("\\addbibresource{") {
return BibTool::BibTeX;
}
}
BibTool::None
}
/// Resolve a TeXLive engine binary to its full path.
/// GUI apps on macOS lack the user's shell PATH, so we check standard
/// TeXLive installation locations and fall back to a login-shell query.
fn find_texlive_binary(name: &str) -> Result<PathBuf, String> {
// 1. Try PATH (works when launched from terminal)
if let Ok(path) = which::which(name) {
return Ok(path);
}
// 2. Check standard TeXLive locations
#[cfg(not(target_os = "windows"))]
{
let standard_paths = [
format!("/Library/TeX/texbin/{}", name),
format!("/usr/local/texlive/2025/bin/universal-darwin/{}", name),
format!("/usr/local/texlive/2024/bin/universal-darwin/{}", name),
format!("/usr/local/texlive/2025/bin/x86_64-linux/{}", name),
format!("/usr/local/texlive/2024/bin/x86_64-linux/{}", name),
format!("/opt/homebrew/bin/{}", name),
format!("/usr/bin/{}", name),
];
for path_str in &standard_paths {
let p = PathBuf::from(path_str);
if p.exists() {
return Ok(p);
}
}
}
#[cfg(target_os = "windows")]
{
let standard_paths = [
format!("C:\\texlive\\2025\\bin\\windows\\{}.exe", name),
format!("C:\\texlive\\2024\\bin\\windows\\{}.exe", name),
];
for path_str in &standard_paths {
let p = PathBuf::from(path_str);
if p.exists() {
return Ok(p);
}
}
}
// 3. macOS: ask login shell for PATH
#[cfg(target_os = "macos")]
{
if let Ok(output) = std::process::Command::new("/bin/zsh")
.args(["-l", "-c", &format!("which {}", name)])
.output()
{
if output.status.success() {
let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string();
let p = PathBuf::from(&resolved);
if p.exists() {
return Ok(p);
}
}
}
}
Err(format!(
"{} not found. Install TeXLive or add it to your PATH.",
name
))
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
if !dst.exists() {
std::fs::create_dir_all(dst)?;
@ -293,6 +389,175 @@ fn compile_with_tectonic_subprocess(work_dir: &Path, main_file: &str) -> Result<
}
}
// --- TeXLive Compilation ---
/// Build a PATH that includes the TeXLive bin directory so that xelatex
/// can find xdvipdfmx, kpsewhich, and other tools it invokes internally.
/// GUI apps on macOS have a minimal PATH that doesn't include TeXLive.
fn texlive_env_path(engine: &Path) -> String {
let texbin = engine
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let current_path = std::env::var("PATH").unwrap_or_default();
if current_path.contains(&texbin) {
current_path
} else {
format!("{}:{}", texbin, current_path)
}
}
/// Run a single TeX engine pass. Never returns `Err` for a non-zero exit
/// code — TeXLive returns non-zero for warnings, font substitutions, etc.
/// The only `Err` is when the process cannot be *spawned* at all.
/// The caller decides success by checking whether the PDF was produced.
fn run_texlive_pass(
engine: &Path,
args: &[&str],
main_file: &Path,
work_dir: &Path,
) -> Result<(), String> {
let output = std::process::Command::new(engine)
.args(args)
.arg(main_file)
.current_dir(work_dir)
.env("PATH", texlive_env_path(engine))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("Failed to launch {}: {}", engine.display(), e))?;
// TeXLive returns non-zero on warnings too — don't fail here.
// The caller decides success by checking whether the PDF was produced.
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!("[texlive] engine stderr: {}", stderr.trim());
}
}
Ok(())
}
fn compile_with_texlive(
work_dir: &Path,
main_file: &str,
engine: Option<TexEngine>,
tex_content: &str,
) -> Result<(), String> {
let engine_name = match engine {
Some(TexEngine::XeLaTeX) | None => "xelatex",
Some(TexEngine::Latex) => "pdflatex",
Some(TexEngine::LuaLaTeX) => "lualatex",
};
let engine_path = find_texlive_binary(engine_name)?;
let env_path = texlive_env_path(&engine_path);
eprintln!("[texlive] backend: {} ({})", engine_name, engine_path.display());
let bib_tool = detect_bib_tool(tex_content);
// Use "." as output-directory since current_dir is already work_dir.
// Absolute paths break when they contain ~ (e.g. iCloud's com~apple~CloudDocs)
// because TeX interprets ~ as a home directory shortcut.
let output_dir_arg = "-output-directory=.".to_string();
// Do NOT use -halt-on-error: xelatex is a pipeline (xetex → .xdv → xdvipdfmx → .pdf).
// With -halt-on-error, recoverable warnings (e.g. missing font shapes) cause xetex to
// exit non-zero, and the xelatex wrapper skips the xdvipdfmx step — producing .xdv but
// no .pdf. -interaction=nonstopmode alone is sufficient to avoid interactive prompts.
let common_args: Vec<&str> = vec![
"-synctex=1",
"-interaction=nonstopmode",
&output_dir_arg,
];
let main_file_path = Path::new(main_file);
// Pass 1
run_texlive_pass(&engine_path, &common_args, main_file_path, work_dir)?;
// Bib pass (if needed)
let main_stem = Path::new(main_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("document");
match bib_tool {
BibTool::Biber => {
let biber_path = find_texlive_binary("biber")?;
let output = std::process::Command::new(&biber_path)
.arg(main_stem)
.current_dir(work_dir)
.env("PATH", &env_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("Failed to run biber: {}", e))?;
if !output.status.success() {
eprintln!(
"[texlive] biber warning: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
BibTool::BibTeX => {
let bibtex_path = find_texlive_binary("bibtex")?;
let aux_file = work_dir.join(format!("{}.aux", main_stem));
let output = std::process::Command::new(&bibtex_path)
.arg(&aux_file)
.current_dir(work_dir)
.env("PATH", &env_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("Failed to run bibtex: {}", e))?;
if !output.status.success() {
eprintln!(
"[texlive] bibtex warning: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
BibTool::None => {}
}
// Pass 2: resolve references / TOC
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
// Pass 3: stabilize citations (only if bib was used)
if !matches!(bib_tool, BibTool::None) {
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
}
let pdf_path = work_dir.join(format!("{}.pdf", main_stem));
let xdv_path = work_dir.join(format!("{}.xdv", main_stem));
// Fallback: if xelatex produced .xdv but no .pdf (e.g. xdvipdfmx was skipped due to
// warnings), manually run xdvipdfmx to convert .xdv → .pdf.
if !pdf_path.exists() && xdv_path.exists() {
eprintln!("[texlive] .xdv exists but no .pdf — running xdvipdfmx manually");
if let Ok(xdvipdfmx) = find_texlive_binary("xdvipdfmx") {
let output = std::process::Command::new(&xdvipdfmx)
.args(["-o", &pdf_path.to_string_lossy()])
.arg(&xdv_path)
.current_dir(work_dir)
.env("PATH", &env_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| format!("Failed to launch xdvipdfmx: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!("[texlive] xdvipdfmx stderr: {}", stderr.trim());
}
}
}
}
// Success is determined by whether the PDF exists, not by exit codes.
// The caller (compile_latex) checks pdf_path.exists() and reads the log for errors.
Ok(())
}
// --- SyncTeX Native Parser ---
struct SynctexNode {
@ -435,11 +700,50 @@ fn parse_synctex_node(s: &str, factor: f64, x_offset: f64, y_offset: f64) -> Opt
// --- Tauri Commands ---
#[derive(serde::Serialize)]
pub struct TexliveStatus {
pub available: bool,
pub engines: Vec<String>,
pub version: Option<String>,
}
#[tauri::command]
pub fn detect_texlive() -> TexliveStatus {
let engines_to_check = ["pdflatex", "xelatex", "lualatex"];
let mut found_engines = Vec::new();
for name in &engines_to_check {
if find_texlive_binary(name).is_ok() {
found_engines.push(name.to_string());
}
}
let version = find_texlive_binary("pdflatex").ok().and_then(|path| {
std::process::Command::new(&path)
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.ok()
.and_then(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.lines().next().map(|l| l.to_string())
})
});
TexliveStatus {
available: !found_engines.is_empty(),
engines: found_engines,
version,
}
}
#[tauri::command]
pub async fn compile_latex(
state: tauri::State<'_, LatexCompilerState>,
project_dir: String,
main_file: String,
use_texlive: Option<bool>,
) -> Result<tauri::ipc::Response, String> {
// Acquire semaphore permit (non-blocking)
let _permit = state
@ -459,6 +763,7 @@ pub async fn compile_latex(
let _project_guard = project_lock.lock().await;
let t0 = std::time::Instant::now();
let use_texlive = use_texlive.unwrap_or(false);
let main_file_name = Path::new(&main_file)
.file_stem()
@ -489,14 +794,15 @@ pub async fn compile_latex(
}
eprintln!(
"[latex] +{:.0}ms {} ({})",
"[latex] +{:.0}ms {} ({}, backend={})",
t0.elapsed().as_millis(),
if is_reuse {
"sync source files"
} else {
"full copy"
},
if is_reuse { "reuse" } else { "first build" }
if is_reuse { "reuse" } else { "first build" },
if use_texlive { "texlive" } else { "tectonic" }
);
// Remove stale PDF so a failed compile doesn't return the previous result.
@ -513,42 +819,70 @@ pub async fn compile_latex(
}
// Detect TeX engine from magic comment
if let Ok(content) = std::fs::read_to_string(&main_tex_path) {
if let Some(engine) = detect_tex_engine(&content) {
if engine == TexEngine::LuaLaTeX {
return Err(
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
Please switch to XeLaTeX or remove the magic comment."
.to_string(),
);
}
// XeLaTeX → native (Tectonic is XeTeX-based), pdflatex → mostly compatible
let main_tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default();
let engine = detect_tex_engine(&main_tex_content);
// Save engine name before `engine` is moved into the spawn_blocking closure
let engine_name_for_label = match &engine {
Some(TexEngine::XeLaTeX) | None => "xelatex",
Some(TexEngine::Latex) => "pdflatex",
Some(TexEngine::LuaLaTeX) => "lualatex",
};
let backend_label = if use_texlive {
format!("TeXLive/{}", engine_name_for_label)
} else {
"Tectonic".to_string()
};
if !use_texlive {
if let Some(TexEngine::LuaLaTeX) = engine {
return Err(
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
Please switch to XeLaTeX or remove the magic comment."
.to_string(),
);
}
}
// Run Tectonic in a subprocess to isolate C-level global state (font cache, etc.).
// This prevents the font_cache assertion failure on retry after a failed compilation.
let work_dir_clone = work_dir.clone();
let main_file_clone = main_file.clone();
let compile_result = tokio::task::spawn_blocking(move || {
lower_thread_priority();
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
})
.await
.map_err(|e| format!("Compilation task panicked: {}", e))?;
eprintln!(
"[latex] +{:.0}ms tectonic done (ok={})",
t0.elapsed().as_millis(),
compile_result.is_ok()
);
let compile_result = if use_texlive {
let work_dir_clone = work_dir.clone();
let main_file_clone = main_file.clone();
let result = tokio::task::spawn_blocking(move || {
lower_thread_priority();
compile_with_texlive(&work_dir_clone, &main_file_clone, engine, &main_tex_content)
})
.await
.map_err(|e| format!("Compilation task panicked: {}", e))?;
eprintln!(
"[latex] +{:.0}ms texlive done (ok={})",
t0.elapsed().as_millis(),
result.is_ok()
);
result
} else {
// Run Tectonic in a subprocess to isolate C-level global state (font cache, etc.).
let work_dir_clone = work_dir.clone();
let main_file_clone = main_file.clone();
let result = tokio::task::spawn_blocking(move || {
lower_thread_priority();
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
})
.await
.map_err(|e| format!("Compilation task panicked: {}", e))?;
eprintln!(
"[latex] +{:.0}ms tectonic done (ok={})",
t0.elapsed().as_millis(),
result.is_ok()
);
result
};
let log_path = work_dir.join(format!("{}.log", main_file_name));
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection
// Skip retry if there are real errors (e.g. missing packages) — retrying won't help.
if !pdf_path.exists() {
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection (Tectonic only).
// TeXLive multi-pass handles this differently; the injection is Tectonic-specific.
if !use_texlive && !pdf_path.exists() {
let log_path_clone = log_path.clone();
let main_tex = work_dir.join(&main_file);
let pdf_path_clone = pdf_path.clone();
@ -610,9 +944,10 @@ pub async fn compile_latex(
.map_err(|e| format!("PDF read task panicked: {}", e))?
.map_err(|e| format!("Failed to read PDF: {}", e))?;
eprintln!(
"[latex] +{:.0}ms total (reuse={}) pdf_size={}KB",
"[latex] +{:.0}ms total (reuse={}, backend={}) pdf_size={}KB",
t0.elapsed().as_millis(),
is_reuse,
backend_label,
pdf_bytes.len() / 1024
);
Ok(tauri::ipc::Response::new(pdf_bytes))
@ -627,7 +962,7 @@ pub async fn compile_latex(
} else {
details
};
Err(format!("Compilation failed\n\n{}", msg))
Err(format!("Compilation failed ({})\n\n{}", backend_label, msg))
}
}
@ -705,6 +1040,44 @@ pub async fn cleanup_all_builds(state: &LatexCompilerState) {
mod tests {
use super::*;
// --- detect_bib_tool ---
#[test]
fn test_detect_bib_tool_biber() {
let content = "\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::Biber);
}
#[test]
fn test_detect_bib_tool_biblatex_with_options() {
let content = "\\documentclass{article}\n\\usepackage[style=apa,backend=biber]{biblatex}\n\\begin{document}";
assert_eq!(detect_bib_tool(content), BibTool::Biber);
}
#[test]
fn test_detect_bib_tool_bibtex() {
let content = "\\documentclass{article}\n\\bibliography{refs}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
}
#[test]
fn test_detect_bib_tool_addbibresource() {
let content = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
}
#[test]
fn test_detect_bib_tool_none() {
let content = "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::None);
}
#[test]
fn test_detect_bib_tool_commented_out() {
let content = "\\documentclass{article}\n% \\bibliography{refs}\n% \\usepackage{biblatex}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::None);
}
// --- extract_error_lines ---
#[test]

View file

@ -355,6 +355,7 @@ pub fn run() {
read_clipboard_file_paths,
latex::compile_latex,
latex::synctex_edit,
latex::detect_texlive,
claude::check_claude_status,
claude::install_claude_cli,
claude::login_claude,

View file

@ -54,6 +54,7 @@ import {
resolveCompileTarget,
formatCompileError,
} from "@/lib/latex-compiler";
import { useSettingsStore } from "@/stores/settings-store";
import { EditorToolbar } from "./editor-toolbar";
import { SelectionToolbar, type ToolbarAction } from "./selection-toolbar";
import { Button } from "@/components/ui/button";
@ -395,7 +396,9 @@ export function LatexEditor() {
.getState()
.createSnapshot(projectRoot, "[compile] Pre-compile")
.catch(() => {});
const data = await compileLatex(projectRoot, targetPath);
const useTexlive =
useSettingsStore.getState().compilerBackend === "texlive";
const data = await compileLatex(projectRoot, targetPath, useTexlive);
setPdfData(data, rootId);
} catch (error) {
setCompileError(formatCompileError(error), rootId);

View file

@ -24,6 +24,7 @@ import {
} from "@/stores/document-store";
import { useHistoryStore } from "@/stores/history-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { useSettingsStore } from "@/stores/settings-store";
import { Button } from "@/components/ui/button";
import {
Select,
@ -86,6 +87,8 @@ const ZOOM_OPTIONS = [
];
export function PdfPreview() {
const compilerBackend = useSettingsStore((s) => s.compilerBackend);
const setCompilerBackend = useSettingsStore((s) => s.setCompilerBackend);
const pdfRevision = useDocumentStore((s) => s.pdfRevision);
const compileError = useDocumentStore((s) => s.compileError);
const isCompiling = useDocumentStore((s) => s.isCompiling);
@ -416,7 +419,9 @@ export function PdfPreview() {
return;
}
const { rootId, targetPath } = resolved;
const data = await compileLatex(projectRoot, targetPath);
const texlive =
useSettingsStore.getState().compilerBackend === "texlive";
const data = await compileLatex(projectRoot, targetPath, texlive);
setPdfData(data, rootId);
} catch (error) {
setCompileError(formatCompileError(error));
@ -553,7 +558,8 @@ export function PdfPreview() {
const compileStart = Date.now();
try {
await saveAllFiles();
const data = await compileLatex(state.projectRoot, targetFile);
const texlive = useSettingsStore.getState().compilerBackend === "texlive";
const data = await compileLatex(state.projectRoot, targetFile, texlive);
setPdfData(data, rootId);
} catch (error) {
setCompileError(formatCompileError(error), rootId);
@ -796,6 +802,20 @@ export function PdfPreview() {
>
<div className="flex h-[calc(40px+var(--titlebar-height))] shrink-0 items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)]">
<div className="flex items-center gap-1">
<Select
value={compilerBackend}
onValueChange={(v) =>
setCompilerBackend(v as "tectonic" | "texlive")
}
>
<SelectTrigger size="sm" className="h-7! w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tectonic">Tectonic</SelectItem>
<SelectItem value="texlive">TeXLive</SelectItem>
</SelectContent>
</Select>
{isSaving && (
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />

View file

@ -8,6 +8,7 @@ import {
import { useDocumentStore } from "@/stores/document-store";
import { useHistoryStore } from "@/stores/history-store";
import { useProposedChangesStore } from "@/stores/proposed-changes-store";
import { useSettingsStore } from "@/stores/settings-store";
import { readTexFileContent } from "@/lib/tauri/fs";
import {
compileLatex,
@ -350,7 +351,13 @@ export function useClaudeEvents() {
useDocumentStore.getState().setPendingRecompile(false);
try {
await useDocumentStore.getState().saveAllFiles();
const pdfData = await compileLatex(projectRoot, targetPath);
const texlive =
useSettingsStore.getState().compilerBackend === "texlive";
const pdfData = await compileLatex(
projectRoot,
targetPath,
texlive,
);
useDocumentStore.getState().setPdfData(pdfData, rootId);
} catch (err) {
useDocumentStore

View file

@ -43,13 +43,17 @@ export function formatCompileError(error: unknown): string {
export async function compileLatex(
projectDir: string,
mainFile: string = "main.tex",
useTexlive: boolean = false,
): Promise<Uint8Array> {
log.info(`Compiling ${mainFile}`);
log.info(
`Compiling ${mainFile} (backend: ${useTexlive ? "texlive" : "tectonic"})`,
);
const start = performance.now();
// compile_latex returns raw PDF bytes via Tauri IPC Response
const buffer = await invoke<ArrayBuffer>("compile_latex", {
projectDir,
mainFile,
useTexlive,
});
const result = new Uint8Array(buffer);
@ -59,6 +63,16 @@ export async function compileLatex(
return result;
}
export interface TexliveStatus {
available: boolean;
engines: string[];
version: string | null;
}
export async function detectTexlive(): Promise<TexliveStatus> {
return invoke<TexliveStatus>("detect_texlive");
}
export interface SynctexResult {
file: string;
line: number;

View file

@ -0,0 +1,21 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
type CompilerBackend = "tectonic" | "texlive";
interface SettingsState {
compilerBackend: CompilerBackend;
setCompilerBackend: (backend: CompilerBackend) => void;
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
compilerBackend: "tectonic",
setCompilerBackend: (backend) => set({ compilerBackend: backend }),
}),
{
name: "claude-prism-settings",
},
),
);