diff --git a/Cargo.toml b/Cargo.toml index 99ff111..bef5592 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" diff --git a/README.md b/README.md index db46e88..9625a02 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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` | --- diff --git a/src/commands/init.rs b/src/commands/init.rs index 93568d1..7dd3f39 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -56,36 +56,34 @@ entry = "{}" /// Find the .tex file that contains \documentclass. fn detect_entry(root: &Path) -> Option { - 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 { + 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 { 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() diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 85f6df7..d815086 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -97,18 +97,41 @@ fn parse_errors(raw: &str) -> Vec { 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 { - // 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 { + // 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()), @@ -116,15 +139,112 @@ fn find_tectonic() -> Result { ] .into_iter() .flatten() + .find(|p| p.exists()) +} + +fn tectonic_managed_path() -> Result { + 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") } diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 607cf5e..f13295e 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -36,48 +36,69 @@ pub fn process(root: &Path, entry: &str) -> Result { std::fs::write(&dest, processed)?; } - // Mirror asset files (non-.tex, non-build) so tectonic resolves relative paths - mirror_assets(root, &build_dir)?; + // Mirror asset files so tectonic resolves relative paths + crate::utils::mirror_assets(root, &build_dir)?; Ok(build_dir.join(entry)) } /// Replace all `\begin{mermaid}[opts]...\end{mermaid}` with figure environments. fn render_diagrams(content: &str, diagrams_dir: &Path, counter: &mut usize) -> Result { + let content = render_env(content, "mermaid", diagrams_dir, counter, |src| { + let svg = mermaid_rs_renderer::render(src) + .map_err(|e| anyhow::anyhow!("Mermaid render error: {}", e))?; + svg_to_png(&svg).context("Failed to convert mermaid SVG to PNG") + })?; + let content = render_env(&content, "graphviz", diagrams_dir, counter, |src| { + let svg = render_graphviz(src)?; + svg_to_png(&svg).context("Failed to convert graphviz SVG to PNG") + })?; + Ok(content) +} + +/// Generic environment renderer: replaces `\begin{env}[opts]...\end{env}` with figure. +pub(crate) fn render_env( + content: &str, + env: &str, + diagrams_dir: &Path, + counter: &mut usize, + render_fn: impl Fn(&str) -> Result>, +) -> Result { + let begin_tag = format!("\\begin{{{}}}", env); + let end_tag = format!("\\end{{{}}}", env); + let mut result = String::new(); let mut remaining: &str = content; - while let Some(start) = remaining.find("\\begin{mermaid}") { + while let Some(start) = remaining.find(&begin_tag) { result.push_str(&remaining[..start]); - let after_begin = &remaining[start + "\\begin{mermaid}".len()..]; - - // Parse optional args: \begin{mermaid}[key=val, ...] + let after_begin = &remaining[start + begin_tag.len()..]; let (opts, after_opts) = parse_opts(after_begin); let end = after_opts - .find("\\end{mermaid}") - .context("\\begin{mermaid} without matching \\end{mermaid}")?; + .find(&*end_tag) + .with_context(|| format!("\\begin{{{}}} without matching \\end{{{}}}", env, env))?; let diagram_src = after_opts[..end].trim(); - // Render SVG → PNG - let svg = mermaid_rs_renderer::render(diagram_src) - .map_err(|e| anyhow::anyhow!("Mermaid render error: {}", e))?; - let png = svg_to_png(&svg).context("Failed to convert mermaid SVG to PNG")?; - - *counter += 1; - let filename = format!("diagram-{}.png", counter); - std::fs::write(diagrams_dir.join(&filename), &png)?; - - // Build figure environment + // Fail fast: validate pos before doing any rendering work let pos = opts.get("pos").map(String::as_str).unwrap_or("H"); if !["H", "t", "b", "h", "p"].contains(&pos) { anyhow::bail!( - "Invalid mermaid option pos='{}' — valid values are: H, t, b, h, p", + "Invalid {} option pos='{}' — valid values are: H, t, b, h, p", + env, pos ); } + + let png = render_fn(diagram_src)?; + + *counter += 1; + let filename = format!("diagram-{}.png", counter); + std::fs::write(diagrams_dir.join(&filename), &png)?; + + // Build figure environment let width = opts .get("width") .map(String::as_str) @@ -94,13 +115,35 @@ fn render_diagrams(content: &str, diagrams_dir: &Path, counter: &mut usize) -> R fig.push_str("\\end{figure}"); result.push_str(&fig); - remaining = &after_opts[end + "\\end{mermaid}".len()..]; + remaining = &after_opts[end + end_tag.len()..]; } result.push_str(remaining); Ok(result) } +/// Render a DOT/Graphviz diagram to SVG using layout-rs (pure Rust). +fn render_graphviz(src: &str) -> Result { + use layout::backends::svg::SVGWriter; + use layout::gv::DotParser; + use layout::gv::GraphBuilder; + use layout::topo::layout::VisualGraph; + + let mut parser = DotParser::new(src); + let graph = parser.process().map_err(|e| { + parser.print_error(); + anyhow::anyhow!("Graphviz parse error: {}", e) + })?; + + let mut builder = GraphBuilder::new(); + builder.visit_graph(&graph); + let mut vg: VisualGraph = builder.get(); + + let mut svg = SVGWriter::new(); + vg.do_it(false, false, false, &mut svg); + Ok(svg.finalize()) +} + /// Parse `[key=val, key2=val2]` into a map. Returns `(map, rest_of_str)`. pub(crate) fn parse_opts(s: &str) -> (HashMap, &str) { let s = s.trim_start_matches('\n').trim_start_matches('\r'); @@ -123,7 +166,7 @@ pub(crate) fn parse_opts(s: &str) -> (HashMap, &str) { (map, rest) } -/// Collect .tex files reachable from entry via \input (non-recursive for simplicity). +/// Collect .tex files reachable from entry via \input. fn collect_tex_files(root: &Path, entry: &str) -> Vec { let mut files = Vec::new(); collect_recursive(root, entry, &mut files); @@ -169,80 +212,6 @@ fn resolve_tex(root: &Path, input: &str) -> PathBuf { } } -/// Mirror asset directories into build/ using symlinks (Unix) or file copy (Windows). -/// Skips .tex files (handled separately) and the build/ dir itself. -fn mirror_assets(root: &Path, build_dir: &Path) -> Result<()> { - for entry in std::fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - - // Skip hidden, build/, and .tex files at root level - if name_str.starts_with('.') || path == build_dir { - continue; - } - - let dest = build_dir.join(&name); - - if path.is_dir() { - // Remove stale symlink/dir if it points somewhere wrong - if dest.exists() || dest.symlink_metadata().is_ok() { - continue; // already linked - } - link_or_copy_dir(&path, &dest)?; - } - // Individual root-level files (e.g. .bib at root) — skip .tex - else if path.is_file() { - let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - if ext == "tex" { - continue; - } - if !dest.exists() { - std::fs::copy(&path, &dest)?; - } - } - } - Ok(()) -} - -#[cfg(unix)] -fn link_or_copy_dir(src: &Path, dest: &Path) -> Result<()> { - // Symlink: dest -> ../dirname (relative from build/) - let target = std::path::Path::new("..").join(src.file_name().unwrap()); - std::os::unix::fs::symlink(&target, dest).with_context(|| { - format!( - "Failed to symlink {} -> {}", - dest.display(), - target.display() - ) - }) -} - -#[cfg(not(unix))] -fn link_or_copy_dir(src: &Path, dest: &Path) -> Result<()> { - // Windows fallback: recursive copy - copy_dir_recursive(src, dest) -} - -#[cfg(not(unix))] -fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { - std::fs::create_dir_all(dest)?; - for entry in walkdir::WalkDir::new(src) - .into_iter() - .filter_map(|e| e.ok()) - { - let rel = entry.path().strip_prefix(src).unwrap(); - let target = dest.join(rel); - if entry.file_type().is_dir() { - std::fs::create_dir_all(&target)?; - } else { - std::fs::copy(entry.path(), &target)?; - } - } - Ok(()) -} - /// Convert SVG string to PNG bytes at 2x scale for print quality. fn svg_to_png(svg: &str) -> Result> { let options = resvg::usvg::Options::default(); @@ -287,4 +256,40 @@ mod tests { let (map, _) = parse_opts("[caption=My diagram]"); assert_eq!(map.get("caption").map(String::as_str), Some("My diagram")); } + + #[test] + fn render_graphviz_produces_svg() { + let dot = "digraph G { A -> B }"; + let svg = render_graphviz(dot).unwrap(); + assert!( + svg.contains(") -> Result String { result } -/// Check mermaid blocks: unclosed and invalid pos option. -fn check_mermaid_blocks(rel: &str, content: &str, errors: &mut Vec) { +/// Check mermaid/graphviz blocks: unclosed and invalid pos option. +fn check_diagram_blocks(rel: &str, content: &str, env: &str, errors: &mut Vec) { const VALID_POS: &[&str] = &["H", "t", "b", "h", "p"]; for (i, line) in content.lines().enumerate() { let line_num = i + 1; let trimmed = line.trim(); - if !trimmed.starts_with("\\begin{mermaid}") { + if !trimmed.starts_with(&format!("\\begin{{{}}}", env)) { continue; } // Check for unclosed block + let end_tag = format!("\\end{{{}}}", env); let rest = &content[content.lines().take(i).map(|l| l.len() + 1).sum::()..]; - if !rest.contains("\\end{mermaid}") { + if !rest.contains(&*end_tag) { errors.push(LintError { file: rel.to_string(), line: line_num, - message: "\\begin{mermaid} without matching \\end{mermaid}".into(), - suggestion: Some("Add \\end{mermaid}".into()), + message: format!("\\begin{{{}}} without matching \\end{{{}}}", env, env), + suggestion: Some(format!("Add \\end{{{}}}", env)), }); continue; } @@ -355,8 +357,8 @@ fn check_mermaid_blocks(rel: &str, content: &str, errors: &mut Vec) { file: rel.to_string(), line: line_num, message: format!( - "\\begin{{mermaid}} invalid pos='{}' — valid values: H, t, b, h, p", - pos + "\\begin{{{}}} invalid pos='{}' — valid values: H, t, b, h, p", + env, pos ), suggestion: Some("Use pos=H, pos=t, pos=b, pos=h, or pos=p".into()), }); @@ -519,4 +521,18 @@ mod tests { let errors = lint(dir.path(), &entry, None).unwrap(); assert!(has_error(&errors, "code.py")); } + + #[test] + fn graphviz_invalid_pos_is_error() { + let (dir, entry) = setup("\\begin{graphviz}[pos=Z]\n\\end{graphviz}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "invalid pos")); + } + + #[test] + fn graphviz_without_end_is_error() { + let (dir, entry) = setup("\\begin{graphviz}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "without matching \\end{graphviz}")); + } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 37acfd1..ac5df65 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,8 @@ use std::path::Path; +use anyhow::Context; + /// Get the `TexForge` data directory (~/.texforge) pub fn data_dir() -> anyhow::Result { let home = @@ -43,3 +45,63 @@ pub fn find_tex_files(root: &Path) -> anyhow::Result> { Ok(files) } + +/// Mirror asset directories into build/ using symlinks (Unix) or file copy (Windows). +/// Skips .tex files (handled by the diagram pre-processor) and build/ itself. +pub fn mirror_assets(root: &Path, build_dir: &Path) -> anyhow::Result<()> { + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + if name_str.starts_with('.') || path == build_dir { + continue; + } + + let dest = build_dir.join(&name); + + if path.is_dir() { + if dest.exists() || dest.symlink_metadata().is_ok() { + continue; + } + link_or_copy_dir(&path, &dest)?; + } else if path.is_file() { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "tex" && !dest.exists() { + std::fs::copy(&path, &dest)?; + } + } + } + Ok(()) +} + +#[cfg(unix)] +fn link_or_copy_dir(src: &Path, dest: &Path) -> anyhow::Result<()> { + let target = std::path::Path::new("..").join(src.file_name().unwrap()); + std::os::unix::fs::symlink(&target, dest).with_context(|| { + format!( + "Failed to symlink {} -> {}", + dest.display(), + target.display() + ) + }) +} + +#[cfg(not(unix))] +fn link_or_copy_dir(src: &Path, dest: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(dest)?; + for entry in walkdir::WalkDir::new(src) + .into_iter() + .filter_map(|e| e.ok()) + { + let rel = entry.path().strip_prefix(src).unwrap(); + let target = dest.join(rel); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&target)?; + } else { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) +}