Skip to content

Commit c6a4410

Browse files
authored
Merge pull request #8 from JheisonMB/develop
feat: graphviz support, auto-install tectonic, and v0.3.0
2 parents 35c2a03 + c59f157 commit c6a4410

7 files changed

Lines changed: 396 additions & 140 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "texforge"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
rust-version = "1.75"
66
description = "Self-contained LaTeX to PDF compiler CLI"
@@ -42,6 +42,9 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
4242
mermaid-rs-renderer = { version = "0.2", default-features = false }
4343
resvg = "0.46"
4444

45+
# Graphviz/DOT diagram rendering
46+
layout-rs = "0.1"
47+
4548
[dev-dependencies]
4649
tempfile = "3.8"
4750

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ cargo build --release
6565

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

68+
### Uninstall
69+
70+
```bash
71+
rm -f ~/.local/bin/texforge # texforge binary
72+
rm -rf ~/.texforge/ # tectonic engine + cached templates
73+
```
74+
6875
---
6976

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

167174
---
168175

176+
## Diagrams
177+
178+
`texforge build` intercepts embedded diagram environments before compilation. Originals are never modified — diagrams are rendered in `build/` copies.
179+
180+
### Mermaid
181+
182+
```latex
183+
% Default: width=\linewidth, pos=H, no caption
184+
\begin{mermaid}
185+
flowchart LR
186+
A[Input] --> B[Process] --> C[Output]
187+
\end{mermaid}
188+
189+
% With options
190+
\begin{mermaid}[width=0.6\linewidth, caption=System flow, pos=t]
191+
flowchart TD
192+
X --> Y --> Z
193+
\end{mermaid}
194+
```
195+
196+
### Graphviz / DOT
197+
198+
```latex
199+
\begin{graphviz}[caption=Pipeline]
200+
digraph G {
201+
rankdir=LR
202+
A -> B -> C
203+
B -> D
204+
}
205+
\end{graphviz}
206+
```
207+
208+
Both rendered to PNG via pure Rust — no browser, no Node.js, no `dot` binary required.
209+
210+
| Option | Default | Description |
211+
|---|---|---|
212+
| `width` | `\linewidth` | Image width |
213+
| `pos` | `H` | Figure placement (`H`, `t`, `b`, `h`, `p`) |
214+
| `caption` | _(none)_ | Figure caption |
215+
216+
---
217+
169218
## Linter
170219

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

247299
---
248300

src/commands/init.rs

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -56,36 +56,34 @@ entry = "{}"
5656

5757
/// Find the .tex file that contains \documentclass.
5858
fn detect_entry(root: &Path) -> Option<String> {
59-
for entry in walkdir::WalkDir::new(root)
60-
.max_depth(2)
61-
.into_iter()
62-
.filter_map(|e| e.ok())
63-
{
64-
let path = entry.path();
65-
if path.extension().and_then(|e| e.to_str()) != Some("tex") {
66-
continue;
67-
}
68-
if let Ok(content) = std::fs::read_to_string(path) {
69-
if content.contains("\\documentclass") {
70-
return path
71-
.strip_prefix(root)
72-
.ok()
73-
.map(|p| p.to_string_lossy().to_string());
74-
}
75-
}
76-
}
77-
None
59+
find_file_by(root, 2, |path, _| {
60+
path.extension().and_then(|e| e.to_str()) == Some("tex")
61+
&& std::fs::read_to_string(path)
62+
.map(|c| c.contains("\\documentclass"))
63+
.unwrap_or(false)
64+
})
7865
}
7966

8067
/// Find the first .bib file in the project.
8168
fn detect_bib(root: &Path) -> Option<String> {
69+
find_file_by(root, 3, |path, _| {
70+
path.extension().and_then(|e| e.to_str()) == Some("bib")
71+
})
72+
}
73+
74+
/// Walk `root` up to `max_depth` and return the first file matching `predicate`.
75+
fn find_file_by(
76+
root: &Path,
77+
max_depth: usize,
78+
predicate: impl Fn(&std::path::Path, &walkdir::DirEntry) -> bool,
79+
) -> Option<String> {
8280
for entry in walkdir::WalkDir::new(root)
83-
.max_depth(3)
81+
.max_depth(max_depth)
8482
.into_iter()
8583
.filter_map(|e| e.ok())
8684
{
8785
let path = entry.path();
88-
if path.extension().and_then(|e| e.to_str()) == Some("bib") {
86+
if path.is_file() && predicate(path, &entry) {
8987
return path
9088
.strip_prefix(root)
9189
.ok()

src/compiler/mod.rs

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -97,34 +97,154 @@ fn parse_errors(raw: &str) -> Vec<CompileError> {
9797
errors
9898
}
9999

100-
/// Find the tectonic binary in PATH or known locations.
100+
/// Find the tectonic binary in PATH or known locations, auto-installing if needed.
101101
fn find_tectonic() -> Result<std::path::PathBuf> {
102-
// Check PATH
103-
if let Ok(output) = Command::new("which").arg("tectonic").output() {
102+
if let Some(path) = locate_tectonic() {
103+
return Ok(path);
104+
}
105+
eprintln!("Tectonic not found. Installing automatically...");
106+
let dest = tectonic_managed_path()?;
107+
install_tectonic(&dest)?;
108+
Ok(dest)
109+
}
110+
111+
/// Locate tectonic in PATH or known install locations without installing.
112+
fn locate_tectonic() -> Option<std::path::PathBuf> {
113+
// Check PATH using platform-appropriate which/where
114+
#[cfg(unix)]
115+
let which_cmd = "which";
116+
#[cfg(not(unix))]
117+
let which_cmd = "where";
118+
119+
if let Ok(output) = Command::new(which_cmd).arg("tectonic").output() {
104120
if output.status.success() {
105-
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
106-
return Ok(path.into());
121+
let path = String::from_utf8_lossy(&output.stdout)
122+
.lines()
123+
.next()
124+
.unwrap_or("")
125+
.trim()
126+
.to_string();
127+
if !path.is_empty() {
128+
return Some(path.into());
129+
}
107130
}
108131
}
109132

110-
// Check known locations (including texforge-managed install)
111-
for candidate in [
133+
// Check known locations
134+
[
112135
dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic")),
113136
dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")),
114137
Some("/usr/local/bin/tectonic".into()),
115138
Some("/opt/homebrew/bin/tectonic".into()),
116139
]
117140
.into_iter()
118141
.flatten()
142+
.find(|p| p.exists())
143+
}
144+
145+
fn tectonic_managed_path() -> Result<std::path::PathBuf> {
146+
dirs::home_dir()
147+
.map(|h| h.join(".texforge/bin/tectonic"))
148+
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
149+
}
150+
151+
/// Download and install tectonic to the given path.
152+
fn install_tectonic(dest: &std::path::Path) -> Result<()> {
153+
let target = current_target()?;
154+
let version = "0.15.0";
155+
let (filename, is_zip) = if target.contains("windows") {
156+
(format!("tectonic-{}-{}.zip", version, target), true)
157+
} else {
158+
(format!("tectonic-{}-{}.tar.gz", version, target), false)
159+
};
160+
161+
let url = format!(
162+
"https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40{}/{}",
163+
version, filename
164+
);
165+
166+
eprintln!("Downloading tectonic {}...", version);
167+
168+
let response = reqwest::blocking::Client::new()
169+
.get(&url)
170+
.header("User-Agent", "texforge")
171+
.send()
172+
.context("Failed to download tectonic")?;
173+
174+
if !response.status().is_success() {
175+
anyhow::bail!(
176+
"Failed to download tectonic: HTTP {}\nURL: {}",
177+
response.status(),
178+
url
179+
);
180+
}
181+
182+
let bytes = response.bytes()?;
183+
184+
if let Some(parent) = dest.parent() {
185+
std::fs::create_dir_all(parent)?;
186+
}
187+
188+
if is_zip {
189+
install_from_zip(&bytes, dest)?;
190+
} else {
191+
install_from_targz(&bytes, dest)?;
192+
}
193+
194+
#[cfg(unix)]
119195
{
120-
if candidate.exists() {
121-
return Ok(candidate);
196+
use std::os::unix::fs::PermissionsExt;
197+
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
198+
}
199+
200+
eprintln!("✅ Tectonic installed to {}", dest.display());
201+
Ok(())
202+
}
203+
204+
fn install_from_targz(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
205+
let decoder = flate2::read::GzDecoder::new(bytes);
206+
let mut archive = tar::Archive::new(decoder);
207+
for entry in archive.entries()? {
208+
let mut entry = entry?;
209+
let path = entry.path()?.to_string_lossy().to_string();
210+
if path.ends_with("tectonic") || path == "tectonic" {
211+
std::io::copy(&mut entry, &mut std::fs::File::create(dest)?)?;
212+
return Ok(());
122213
}
123214
}
215+
anyhow::bail!("tectonic binary not found in archive")
216+
}
124217

125-
anyhow::bail!(
126-
"Tectonic not found. Install everything with:\n\
127-
\n curl -fsSL https://raw.githubusercontent.com/JheisonMB/texforge/main/install.sh | sh\n\
128-
\nor install tectonic separately: cargo install tectonic"
129-
);
218+
fn install_from_zip(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
219+
let cursor = std::io::Cursor::new(bytes);
220+
let mut archive = zip::ZipArchive::new(cursor)?;
221+
for i in 0..archive.len() {
222+
let mut file = archive.by_index(i)?;
223+
if file.name().ends_with("tectonic.exe") || file.name() == "tectonic.exe" {
224+
std::io::copy(&mut file, &mut std::fs::File::create(dest)?)?;
225+
return Ok(());
226+
}
227+
}
228+
anyhow::bail!("tectonic.exe not found in archive")
229+
}
230+
231+
fn current_target() -> Result<&'static str> {
232+
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
233+
return Ok("x86_64-unknown-linux-musl");
234+
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
235+
return Ok("aarch64-unknown-linux-musl");
236+
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
237+
return Ok("x86_64-apple-darwin");
238+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
239+
return Ok("aarch64-apple-darwin");
240+
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
241+
return Ok("x86_64-pc-windows-msvc");
242+
#[cfg(not(any(
243+
all(target_os = "linux", target_arch = "x86_64"),
244+
all(target_os = "linux", target_arch = "aarch64"),
245+
all(target_os = "macos", target_arch = "x86_64"),
246+
all(target_os = "macos", target_arch = "aarch64"),
247+
all(target_os = "windows", target_arch = "x86_64"),
248+
)))]
249+
anyhow::bail!("Unsupported platform for automatic tectonic installation")
130250
}

0 commit comments

Comments
 (0)