Skip to content
5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "texforge"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
rust-version = "1.75"
description = "Self-contained LaTeX to PDF compiler CLI"
Expand Down Expand Up @@ -42,6 +42,9 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
mermaid-rs-renderer = { version = "0.2", default-features = false }
resvg = "0.46"

# Graphviz/DOT diagram rendering
layout-rs = "0.1"

[dev-dependencies]
tempfile = "3.8"

Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ cargo build --release

Check the [Releases](https://github.com/JheisonMB/texforge/releases) page for precompiled binaries (Linux x86_64, macOS x86_64/ARM64, Windows x86_64).

### Uninstall

```bash
rm -f ~/.local/bin/texforge # texforge binary
rm -rf ~/.texforge/ # tectonic engine + cached templates
```

---

## Quick Start
Expand Down Expand Up @@ -166,6 +173,48 @@ Templates are cached locally in `~/.texforge/templates/` after first download.

---

## Diagrams

`texforge build` intercepts embedded diagram environments before compilation. Originals are never modified — diagrams are rendered in `build/` copies.

### Mermaid

```latex
% Default: width=\linewidth, pos=H, no caption
\begin{mermaid}
flowchart LR
A[Input] --> B[Process] --> C[Output]
\end{mermaid}

% With options
\begin{mermaid}[width=0.6\linewidth, caption=System flow, pos=t]
flowchart TD
X --> Y --> Z
\end{mermaid}
```

### Graphviz / DOT

```latex
\begin{graphviz}[caption=Pipeline]
digraph G {
rankdir=LR
A -> B -> C
B -> D
}
\end{graphviz}
```

Both rendered to PNG via pure Rust — no browser, no Node.js, no `dot` binary required.

| Option | Default | Description |
|---|---|---|
| `width` | `\linewidth` | Image width |
| `pos` | `H` | Figure placement (`H`, `t`, `b`, `h`, `p`) |
| `caption` | _(none)_ | Figure caption |

---

## Linter

`texforge check` runs static analysis without compiling:
Expand Down Expand Up @@ -243,6 +292,9 @@ texforge fmt --check # check without modifying (CI-friendly)
| Archive extraction | `flate2` + `tar` |
| File traversal | `walkdir` |
| LaTeX engine | `tectonic` (external binary) |
| Mermaid renderer | `mermaid-rs-renderer` |
| Graphviz renderer | `layout-rs` |
| SVG → PNG | `resvg` |

---

Expand Down
40 changes: 19 additions & 21 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,36 +56,34 @@ entry = "{}"

/// Find the .tex file that contains \documentclass.
fn detect_entry(root: &Path) -> Option<String> {
for entry in walkdir::WalkDir::new(root)
.max_depth(2)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("tex") {
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
if content.contains("\\documentclass") {
return path
.strip_prefix(root)
.ok()
.map(|p| p.to_string_lossy().to_string());
}
}
}
None
find_file_by(root, 2, |path, _| {
path.extension().and_then(|e| e.to_str()) == Some("tex")
&& std::fs::read_to_string(path)
.map(|c| c.contains("\\documentclass"))
.unwrap_or(false)
})
}

/// Find the first .bib file in the project.
fn detect_bib(root: &Path) -> Option<String> {
find_file_by(root, 3, |path, _| {
path.extension().and_then(|e| e.to_str()) == Some("bib")
})
}

/// Walk `root` up to `max_depth` and return the first file matching `predicate`.
fn find_file_by(
root: &Path,
max_depth: usize,
predicate: impl Fn(&std::path::Path, &walkdir::DirEntry) -> bool,
) -> Option<String> {
for entry in walkdir::WalkDir::new(root)
.max_depth(3)
.max_depth(max_depth)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("bib") {
if path.is_file() && predicate(path, &entry) {
return path
.strip_prefix(root)
.ok()
Expand Down
148 changes: 134 additions & 14 deletions src/compiler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,34 +97,154 @@ fn parse_errors(raw: &str) -> Vec<CompileError> {
errors
}

/// Find the tectonic binary in PATH or known locations.
/// Find the tectonic binary in PATH or known locations, auto-installing if needed.
fn find_tectonic() -> Result<std::path::PathBuf> {
// Check PATH
if let Ok(output) = Command::new("which").arg("tectonic").output() {
if let Some(path) = locate_tectonic() {
return Ok(path);
}
eprintln!("Tectonic not found. Installing automatically...");
let dest = tectonic_managed_path()?;
install_tectonic(&dest)?;
Ok(dest)
}

/// Locate tectonic in PATH or known install locations without installing.
fn locate_tectonic() -> Option<std::path::PathBuf> {
// Check PATH using platform-appropriate which/where
#[cfg(unix)]
let which_cmd = "which";
#[cfg(not(unix))]
let which_cmd = "where";

if let Ok(output) = Command::new(which_cmd).arg("tectonic").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(path.into());
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Some(path.into());
}
}
}

// Check known locations (including texforge-managed install)
for candidate in [
// Check known locations
[
dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic")),
dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")),
Some("/usr/local/bin/tectonic".into()),
Some("/opt/homebrew/bin/tectonic".into()),
]
.into_iter()
.flatten()
.find(|p| p.exists())
}

fn tectonic_managed_path() -> Result<std::path::PathBuf> {
dirs::home_dir()
.map(|h| h.join(".texforge/bin/tectonic"))
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
}

/// Download and install tectonic to the given path.
fn install_tectonic(dest: &std::path::Path) -> Result<()> {
let target = current_target()?;
let version = "0.15.0";
let (filename, is_zip) = if target.contains("windows") {
(format!("tectonic-{}-{}.zip", version, target), true)
} else {
(format!("tectonic-{}-{}.tar.gz", version, target), false)
};

let url = format!(
"https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40{}/{}",
version, filename
);

eprintln!("Downloading tectonic {}...", version);

let response = reqwest::blocking::Client::new()
.get(&url)
.header("User-Agent", "texforge")
.send()
.context("Failed to download tectonic")?;

if !response.status().is_success() {
anyhow::bail!(
"Failed to download tectonic: HTTP {}\nURL: {}",
response.status(),
url
);
}

let bytes = response.bytes()?;

if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}

if is_zip {
install_from_zip(&bytes, dest)?;
} else {
install_from_targz(&bytes, dest)?;
}

#[cfg(unix)]
{
if candidate.exists() {
return Ok(candidate);
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
}

eprintln!("✅ Tectonic installed to {}", dest.display());
Ok(())
}

fn install_from_targz(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
let decoder = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_string_lossy().to_string();
if path.ends_with("tectonic") || path == "tectonic" {
std::io::copy(&mut entry, &mut std::fs::File::create(dest)?)?;
return Ok(());
}
}
anyhow::bail!("tectonic binary not found in archive")
}

anyhow::bail!(
"Tectonic not found. Install everything with:\n\
\n curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh\n\
\nor install tectonic separately: cargo install tectonic"
);
fn install_from_zip(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.name().ends_with("tectonic.exe") || file.name() == "tectonic.exe" {
std::io::copy(&mut file, &mut std::fs::File::create(dest)?)?;
return Ok(());
}
}
anyhow::bail!("tectonic.exe not found in archive")
}

fn current_target() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
return Ok("x86_64-unknown-linux-musl");
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
return Ok("aarch64-unknown-linux-musl");
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
return Ok("x86_64-apple-darwin");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
return Ok("aarch64-apple-darwin");
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
return Ok("x86_64-pc-windows-msvc");
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
anyhow::bail!("Unsupported platform for automatic tectonic installation")
}
Loading
Loading