Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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