From 6eb719675cc9282287093f44abcb0a6cb9f06bc1 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:10:32 -0500 Subject: [PATCH 01/15] feat: render embedded mermaid diagrams to PNG before tectonic compilation --- Cargo.toml | 4 ++ src/commands/build.rs | 4 ++ src/diagrams/mod.rs | 129 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 138 insertions(+) create mode 100644 src/diagrams/mod.rs diff --git a/Cargo.toml b/Cargo.toml index b8e45c5..dd56c5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,10 @@ flate2 = "1.1" tar = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } +# Mermaid diagram rendering +mermaid-rs-renderer = { version = "0.2", default-features = false } +resvg = "0.46" + [dev-dependencies] tempfile = "3.8" diff --git a/src/commands/build.rs b/src/commands/build.rs index f8ac2ab..26bfa27 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -3,6 +3,7 @@ use anyhow::Result; use crate::compiler; +use crate::diagrams; use crate::domain::project::Project; /// Compile project to PDF. @@ -14,6 +15,9 @@ pub fn execute() -> Result<()> { // Ensure build directory exists std::fs::create_dir_all(project.root.join("build"))?; + // Pre-process embedded diagrams (mermaid, etc.) + diagrams::process(&project.root, &project.config.compilacion.entry)?; + compiler::compile(&project.root, &project.config.compilacion.entry)?; let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs new file mode 100644 index 0000000..1341622 --- /dev/null +++ b/src/diagrams/mod.rs @@ -0,0 +1,129 @@ +//! Pre-processor for embedded diagram environments. +//! +//! Intercepts `\begin{mermaid}...\end{mermaid}` blocks before tectonic sees +//! the file, renders them to SVG, and replaces each block with +//! `\includegraphics{build/diagrams/diagram-N.svg}`. + +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Process all .tex files reachable from `entry`, rendering embedded diagrams. +/// Returns true if any diagrams were rendered (so the caller knows files changed). +pub fn process(root: &Path, entry: &str) -> Result { + let entry_path = root.join(entry); + let content = std::fs::read_to_string(&entry_path)?; + + let diagrams_dir = root.join("build/diagrams"); + std::fs::create_dir_all(&diagrams_dir)?; + + let mut any = false; + any |= process_file(root, &entry_path, &diagrams_dir)?; + + // Also process \input-ted files + for line in content.lines() { + for input in extract_inputs(line) { + let path = resolve_tex(root, input); + if path.exists() { + any |= process_file(root, &path, &diagrams_dir)?; + } + } + } + + Ok(any) +} + +fn process_file(root: &Path, path: &Path, diagrams_dir: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + if !content.contains("\\begin{mermaid}") { + return Ok(false); + } + + let (new_content, count) = render_diagrams(&content, diagrams_dir) + .with_context(|| format!("Failed to render diagrams in {}", path.display()))?; + + if count > 0 { + std::fs::write(path, &new_content)?; + eprintln!(" rendered {} mermaid diagram(s) in {}", count, path.strip_prefix(root).unwrap_or(path).display()); + } + + Ok(count > 0) +} + +/// Replace all `\begin{mermaid}...\end{mermaid}` blocks with `\includegraphics`. +fn render_diagrams(content: &str, diagrams_dir: &Path) -> Result<(String, usize)> { + let mut result = String::new(); + let mut remaining: &str = &content; + let mut count = 0; + + while let Some(start) = remaining.find("\\begin{mermaid}") { + result.push_str(&remaining[..start]); + + let after_begin = &remaining[start + "\\begin{mermaid}".len()..]; + let end = after_begin + .find("\\end{mermaid}") + .context("\\begin{mermaid} without matching \\end{mermaid}")?; + + let diagram_src = after_begin[..end].trim(); + + // Render to PNG (LaTeX-compatible without extra packages) + let svg_str = mermaid_rs_renderer::render(diagram_src) + .map_err(|e| anyhow::anyhow!("Mermaid render error: {}", e))?; + let png = svg_to_png(&svg_str) + .context("Failed to convert mermaid SVG to PNG")?; + + // Save PNG file + let filename = format!("diagram-{}.png", count + 1); + let img_path = diagrams_dir.join(&filename); + std::fs::write(&img_path, &png)?; + + // Replace with \includegraphics + let rel = format!("build/diagrams/{}", filename); + result.push_str(&format!("\\includegraphics{{{}}}", rel)); + + remaining = &after_begin[end + "\\end{mermaid}".len()..]; + count += 1; + } + + result.push_str(remaining); + Ok((result, count)) +} + +fn extract_inputs(line: &str) -> Vec<&str> { + let mut results = Vec::new(); + let mut search = line; + while let Some(pos) = search.find("\\input{") { + let after = &search[pos + 7..]; + if let Some(end) = after.find('}') { + results.push(after[..end].trim()); + search = &after[end + 1..]; + } else { + break; + } + } + results +} + +fn resolve_tex(root: &Path, input: &str) -> std::path::PathBuf { + let p = root.join(input); + if p.extension().is_some() { p } else { p.with_extension("tex") } +} + +/// Convert SVG string to PNG bytes using resvg (2x scale for print quality). +fn svg_to_png(svg: &str) -> Result> { + let options = resvg::usvg::Options::default(); + let tree = resvg::usvg::Tree::from_str(svg, &options) + .context("Failed to parse SVG")?; + + let scale = 2.0_f32; + let width = (tree.size().width() * scale) as u32; + let height = (tree.size().height() * scale) as u32; + + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) + .context("Failed to create pixmap")?; + + let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + pixmap.encode_png().context("Failed to encode PNG") +} diff --git a/src/main.rs b/src/main.rs index a78c83c..39d0a57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod cli; mod commands; mod compiler; +mod diagrams; mod domain; mod formatter; mod linter; From 6048723a69b86f685b32b6d48af0393d59b86bf9 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:17:17 -0500 Subject: [PATCH 02/15] fix: process diagrams on build/ copies, preserve originals, support figure options --- src/commands/build.rs | 14 +++- src/compiler/mod.rs | 3 +- src/diagrams/mod.rs | 171 ++++++++++++++++++++++++++---------------- 3 files changed, 120 insertions(+), 68 deletions(-) diff --git a/src/commands/build.rs b/src/commands/build.rs index 26bfa27..4df8817 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -12,13 +12,19 @@ pub fn execute() -> Result<()> { println!("Building project: {}", project.config.documento.titulo); - // Ensure build directory exists std::fs::create_dir_all(project.root.join("build"))?; - // Pre-process embedded diagrams (mermaid, etc.) - diagrams::process(&project.root, &project.config.compilacion.entry)?; + // Pre-process embedded diagrams — works on copies in build/, originals untouched + let build_entry = diagrams::process(&project.root, &project.config.compilacion.entry)?; - compiler::compile(&project.root, &project.config.compilacion.entry)?; + // Compile from build/ so relative paths resolve correctly + let build_dir = project.root.join("build"); + let entry_filename = std::path::Path::new(&project.config.compilacion.entry) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or(project.config.compilacion.entry.clone()); + + compiler::compile(&build_dir, &entry_filename)?; let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); println!("✅ build/{}", pdf_name.display()); diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 2c58f72..4487f6e 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -6,6 +6,7 @@ use std::process::Command; use anyhow::{Context, Result}; /// Compile a LaTeX project to PDF using Tectonic. +/// `root` is the working directory; output PDF goes into `root` itself. pub fn compile(root: &Path, entry: &str) -> Result<()> { let tectonic = find_tectonic()?; let entry_path = root.join(entry); @@ -13,7 +14,7 @@ pub fn compile(root: &Path, entry: &str) -> Result<()> { let output = Command::new(&tectonic) .arg(&entry_path) .arg("--outdir") - .arg(root.join("build")) + .arg(root) .arg("--keep-logs") .current_dir(root) .output() diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 1341622..36779ce 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -1,92 +1,138 @@ //! Pre-processor for embedded diagram environments. //! -//! Intercepts `\begin{mermaid}...\end{mermaid}` blocks before tectonic sees -//! the file, renders them to SVG, and replaces each block with -//! `\includegraphics{build/diagrams/diagram-N.svg}`. +//! Intercepts `\begin{mermaid}[opts]...\end{mermaid}` blocks, renders them +//! to PNG, and replaces each block with a proper `figure` environment. +//! +//! Works on copies in `build/` — the original .tex files are never modified. -use std::path::Path; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -/// Process all .tex files reachable from `entry`, rendering embedded diagrams. -/// Returns true if any diagrams were rendered (so the caller knows files changed). -pub fn process(root: &Path, entry: &str) -> Result { - let entry_path = root.join(entry); - let content = std::fs::read_to_string(&entry_path)?; +/// Copy all .tex files to `build/`, rendering embedded diagrams in the copies. +/// Returns the path to the build copy of `entry`. +pub fn process(root: &Path, entry: &str) -> Result { + let build_dir = root.join("build"); + std::fs::create_dir_all(&build_dir)?; - let diagrams_dir = root.join("build/diagrams"); + let diagrams_dir = build_dir.join("diagrams"); std::fs::create_dir_all(&diagrams_dir)?; - let mut any = false; - any |= process_file(root, &entry_path, &diagrams_dir)?; + // Counter shared across all files so diagram names are unique + let mut counter = 0usize; - // Also process \input-ted files - for line in content.lines() { - for input in extract_inputs(line) { - let path = resolve_tex(root, input); - if path.exists() { - any |= process_file(root, &path, &diagrams_dir)?; - } - } - } + // Collect all .tex files reachable from entry + let tex_files = collect_tex_files(root, entry); - Ok(any) -} - -fn process_file(root: &Path, path: &Path, diagrams_dir: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - if !content.contains("\\begin{mermaid}") { - return Ok(false); - } - - let (new_content, count) = render_diagrams(&content, diagrams_dir) - .with_context(|| format!("Failed to render diagrams in {}", path.display()))?; - - if count > 0 { - std::fs::write(path, &new_content)?; - eprintln!(" rendered {} mermaid diagram(s) in {}", count, path.strip_prefix(root).unwrap_or(path).display()); + for src in &tex_files { + let rel = src.strip_prefix(root).unwrap_or(src); + let dest = build_dir.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let content = std::fs::read_to_string(src)?; + let processed = render_diagrams(&content, &diagrams_dir, &mut counter) + .with_context(|| format!("Failed to render diagrams in {}", src.display()))?; + std::fs::write(&dest, processed)?; } - Ok(count > 0) + Ok(build_dir.join(entry)) } -/// Replace all `\begin{mermaid}...\end{mermaid}` blocks with `\includegraphics`. -fn render_diagrams(content: &str, diagrams_dir: &Path) -> Result<(String, usize)> { +/// Replace all `\begin{mermaid}[opts]...\end{mermaid}` with figure environments. +fn render_diagrams(content: &str, diagrams_dir: &Path, counter: &mut usize) -> Result { let mut result = String::new(); - let mut remaining: &str = &content; - let mut count = 0; + let mut remaining: &str = content; while let Some(start) = remaining.find("\\begin{mermaid}") { result.push_str(&remaining[..start]); let after_begin = &remaining[start + "\\begin{mermaid}".len()..]; - let end = after_begin + + // Parse optional args: \begin{mermaid}[key=val, ...] + let (opts, after_opts) = parse_opts(after_begin); + + let end = after_opts .find("\\end{mermaid}") .context("\\begin{mermaid} without matching \\end{mermaid}")?; - let diagram_src = after_begin[..end].trim(); + let diagram_src = after_opts[..end].trim(); - // Render to PNG (LaTeX-compatible without extra packages) - let svg_str = mermaid_rs_renderer::render(diagram_src) + // 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_str) - .context("Failed to convert mermaid SVG to PNG")?; + 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 + let pos = opts.get("pos").map(String::as_str).unwrap_or("H"); + let width = opts.get("width").map(String::as_str).unwrap_or("\\linewidth"); + let caption = opts.get("caption"); + let rel_path = format!("diagrams/{}", filename); + + let mut fig = format!( + "\\begin{{figure}}[{pos}]\n \\centering\n \\includegraphics[width={width}]{{{rel_path}}}\n" + ); + if let Some(cap) = caption { + fig.push_str(&format!(" \\caption{{{}}}\n", cap)); + } + fig.push_str("\\end{figure}"); - // Save PNG file - let filename = format!("diagram-{}.png", count + 1); - let img_path = diagrams_dir.join(&filename); - std::fs::write(&img_path, &png)?; + result.push_str(&fig); + remaining = &after_opts[end + "\\end{mermaid}".len()..]; + } - // Replace with \includegraphics - let rel = format!("build/diagrams/{}", filename); - result.push_str(&format!("\\includegraphics{{{}}}", rel)); + result.push_str(remaining); + Ok(result) +} - remaining = &after_begin[end + "\\end{mermaid}".len()..]; - count += 1; +/// Parse `[key=val, key2=val2]` into a map. Returns (map, rest_of_str). +fn parse_opts(s: &str) -> (HashMap, &str) { + let s = s.trim_start_matches('\n').trim_start_matches('\r'); + if !s.starts_with('[') { + return (HashMap::new(), s); } + let end = match s.find(']') { + Some(i) => i, + None => return (HashMap::new(), s), + }; + let inner = &s[1..end]; + let rest = &s[end + 1..]; + + let mut map = HashMap::new(); + for part in inner.split(',') { + let part = part.trim(); + if let Some((k, v)) = part.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + (map, rest) +} - result.push_str(remaining); - Ok((result, count)) +/// Collect .tex files reachable from entry via \input (non-recursive for simplicity). +fn collect_tex_files(root: &Path, entry: &str) -> Vec { + let mut files = Vec::new(); + collect_recursive(root, entry, &mut files); + files +} + +fn collect_recursive(root: &Path, entry: &str, files: &mut Vec) { + let path = resolve_tex(root, entry); + if !path.exists() || files.contains(&path) { + return; + } + files.push(path.clone()); + if let Ok(content) = std::fs::read_to_string(&path) { + for line in content.lines() { + for input in extract_inputs(line) { + collect_recursive(root, input, files); + } + } + } } fn extract_inputs(line: &str) -> Vec<&str> { @@ -104,26 +150,25 @@ fn extract_inputs(line: &str) -> Vec<&str> { results } -fn resolve_tex(root: &Path, input: &str) -> std::path::PathBuf { +fn resolve_tex(root: &Path, input: &str) -> PathBuf { let p = root.join(input); if p.extension().is_some() { p } else { p.with_extension("tex") } } -/// Convert SVG string to PNG bytes using resvg (2x scale for print quality). +/// 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(); let tree = resvg::usvg::Tree::from_str(svg, &options) .context("Failed to parse SVG")?; let scale = 2.0_f32; - let width = (tree.size().width() * scale) as u32; + let width = (tree.size().width() * scale) as u32; let height = (tree.size().height() * scale) as u32; let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) .context("Failed to create pixmap")?; - let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); - resvg::render(&tree, transform, &mut pixmap.as_mut()); + resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut()); pixmap.encode_png().context("Failed to encode PNG") } From 9bfc6c580047be4cf4701efad861534679254617 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:26:23 -0500 Subject: [PATCH 03/15] fix: add float package to embedded general template for mermaid diagram support --- src/templates/general/main.tex | 1 + 1 file changed, 1 insertion(+) diff --git a/src/templates/general/main.tex b/src/templates/general/main.tex index 9487ed7..c49127e 100644 --- a/src/templates/general/main.tex +++ b/src/templates/general/main.tex @@ -6,6 +6,7 @@ \usepackage[T1]{fontenc} \usepackage[spanish]{babel} \usepackage{amsmath} +\usepackage{float} \usepackage{graphicx} \usepackage{hyperref} \usepackage{geometry} From eff6af51327b258d146e69d2d6e1889488e32d4d Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:30:19 -0500 Subject: [PATCH 04/15] fix: validate mermaid pos option against valid float placements --- src/diagrams/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 36779ce..8a9d4e3 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -69,7 +69,13 @@ fn render_diagrams(content: &str, diagrams_dir: &Path, counter: &mut usize) -> R std::fs::write(diagrams_dir.join(&filename), &png)?; // Build figure environment - let pos = opts.get("pos").map(String::as_str).unwrap_or("H"); + 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", + pos + ); + } let width = opts.get("width").map(String::as_str).unwrap_or("\\linewidth"); let caption = opts.get("caption"); let rel_path = format!("diagrams/{}", filename); From efba76672afdf48500f689f034836ecdd3953998 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:36:36 -0500 Subject: [PATCH 05/15] fix: mirror assets to build/, exclude build/ from fmt, add clean command, lint mermaid blocks --- src/cli/mod.rs | 3 +++ src/commands/build.rs | 3 +-- src/commands/clean.rs | 20 ++++++++++++++++ src/commands/mod.rs | 1 + src/compiler/mod.rs | 2 +- src/diagrams/mod.rs | 46 ++++++++++++++++++++++++++++++++--- src/linter/mod.rs | 56 +++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 11 ++++++--- 8 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 src/commands/clean.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 88a297b..50f0bcf 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -15,6 +15,8 @@ pub struct Cli { #[derive(Subcommand)] enum Commands { + /// Remove build artifacts + Clean, /// Initialize a texforge project in the current directory Init, /// Create a new project from a template @@ -61,6 +63,7 @@ enum TemplateAction { impl Cli { pub fn execute(self) -> Result<()> { match self.command { + Commands::Clean => commands::clean::execute(), Commands::Init => commands::init::execute(), Commands::New { name, template } => commands::new::execute(&name, template.as_deref()), Commands::Build => commands::build::execute(), diff --git a/src/commands/build.rs b/src/commands/build.rs index 4df8817..c34eef1 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -17,13 +17,12 @@ pub fn execute() -> Result<()> { // Pre-process embedded diagrams — works on copies in build/, originals untouched let build_entry = diagrams::process(&project.root, &project.config.compilacion.entry)?; - // Compile from build/ so relative paths resolve correctly + // Compile from build/ — all assets are mirrored there, diagrams use relative paths let build_dir = project.root.join("build"); let entry_filename = std::path::Path::new(&project.config.compilacion.entry) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or(project.config.compilacion.entry.clone()); - compiler::compile(&build_dir, &entry_filename)?; let pdf_name = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); diff --git a/src/commands/clean.rs b/src/commands/clean.rs new file mode 100644 index 0000000..5a6d338 --- /dev/null +++ b/src/commands/clean.rs @@ -0,0 +1,20 @@ +//! `texforge clean` command implementation. + +use anyhow::Result; + +use crate::domain::project::Project; + +/// Remove the build/ directory. +pub fn execute() -> Result<()> { + let project = Project::load()?; + let build_dir = project.root.join("build"); + + if !build_dir.exists() { + println!("Nothing to clean."); + return Ok(()); + } + + std::fs::remove_dir_all(&build_dir)?; + println!("✅ build/ removed"); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a6279fe..23f89e6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod check; +pub mod clean; pub mod fmt; pub mod init; pub mod new; diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 4487f6e..289bf65 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -6,7 +6,7 @@ use std::process::Command; use anyhow::{Context, Result}; /// Compile a LaTeX project to PDF using Tectonic. -/// `root` is the working directory; output PDF goes into `root` itself. +/// `root` is the working directory; output PDF goes into `root/` itself. pub fn compile(root: &Path, entry: &str) -> Result<()> { let tectonic = find_tectonic()?; let entry_path = root.join(entry); diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 8a9d4e3..bfd095a 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; /// Copy all .tex files to `build/`, rendering embedded diagrams in the copies. +/// Also mirrors non-.tex assets so tectonic can resolve relative paths. /// Returns the path to the build copy of `entry`. pub fn process(root: &Path, entry: &str) -> Result { let build_dir = root.join("build"); @@ -19,12 +20,10 @@ pub fn process(root: &Path, entry: &str) -> Result { let diagrams_dir = build_dir.join("diagrams"); std::fs::create_dir_all(&diagrams_dir)?; - // Counter shared across all files so diagram names are unique let mut counter = 0usize; - // Collect all .tex files reachable from entry + // Process .tex files let tex_files = collect_tex_files(root, entry); - for src in &tex_files { let rel = src.strip_prefix(root).unwrap_or(src); let dest = build_dir.join(rel); @@ -37,6 +36,9 @@ 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)?; + Ok(build_dir.join(entry)) } @@ -161,6 +163,44 @@ fn resolve_tex(root: &Path, input: &str) -> PathBuf { if p.extension().is_some() { p } else { p.with_extension("tex") } } +/// Mirror non-.tex asset files into build/ so tectonic resolves relative paths. +/// Skips the build/ directory itself to avoid recursion. +fn mirror_assets(root: &Path, build_dir: &Path) -> Result<()> { + for entry in walkdir::WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + + // Skip build/ itself + if path.starts_with(build_dir) { + continue; + } + // Skip hidden dirs (.git, etc.) + if path.components().any(|c| { + c.as_os_str().to_string_lossy().starts_with('.') + }) { + continue; + } + + if path.is_file() { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + // Only mirror asset files, not .tex (those are handled separately) + if ext == "tex" { + continue; + } + let rel = path.strip_prefix(root).unwrap_or(path); + let dest = build_dir.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + // Always copy — ensures assets stay in sync with source + std::fs::copy(path, &dest)?; + } + } + 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(); diff --git a/src/linter/mod.rs b/src/linter/mod.rs index d9675b2..1957df2 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -80,6 +80,7 @@ pub fn lint(root: &Path, entry: &str, bib_file: Option<&str>) -> Result String { result } +/// Check mermaid blocks: unclosed and invalid pos option. +fn check_mermaid_blocks(rel: &str, content: &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}") { + continue; + } + + // Check for unclosed block + let rest = &content[content + .lines() + .take(i) + .map(|l| l.len() + 1) + .sum::()..]; + if !rest.contains("\\end{mermaid}") { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: "\\begin{mermaid} without matching \\end{mermaid}".into(), + suggestion: Some("Add \\end{mermaid}".into()), + }); + continue; + } + + // Check pos option if present + if let Some(opts_start) = trimmed.find('[') { + if let Some(opts_end) = trimmed.find(']') { + let opts = &trimmed[opts_start + 1..opts_end]; + for part in opts.split(',') { + if let Some((k, v)) = part.split_once('=') { + if k.trim() == "pos" { + let pos = v.trim(); + if !VALID_POS.contains(&pos) { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!( + "\\begin{{mermaid}} invalid pos='{}' — valid values: H, t, b, h, p", + pos + ), + suggestion: Some("Use pos=H, pos=t, pos=b, pos=h, or pos=p".into()), + }); + } + } + } + } + } + } + } +} + /// Parse `@type{key, ...}` entries from a .bib file. fn parse_bib_keys(path: &Path) -> HashSet { let mut keys = HashSet::new(); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1057cfe..37acfd1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -18,19 +18,24 @@ pub fn templates_dir() -> anyhow::Result { Ok(dir) } -/// Find all .tex files in a directory +/// Find all .tex files in a directory, excluding build/ pub fn find_tex_files(root: &Path) -> anyhow::Result> { let mut files = Vec::new(); + let build_dir = root.join("build"); for entry in walkdir::WalkDir::new(root) .follow_links(true) .into_iter() .filter_map(|e| e.ok()) { + let path = entry.path(); + if path.starts_with(&build_dir) { + continue; + } if entry.file_type().is_file() { - if let Some(ext) = entry.path().extension() { + if let Some(ext) = path.extension() { if ext == "tex" { - files.push(entry.path().to_path_buf()); + files.push(path.to_path_buf()); } } } From f43f7b5d4a49b7218eebe6b7edbfded2536d0766 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:41:36 -0500 Subject: [PATCH 06/15] chore: add init and clean command modules to mermaid-support branch --- src/commands/init.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/init.rs b/src/commands/init.rs index 93568d1..eaefe93 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -13,7 +13,8 @@ pub fn execute() -> Result<()> { } // Detect entry point: file with \documentclass - let entry = detect_entry(&root).unwrap_or_else(|| "main.tex".to_string()); + let entry = detect_entry(&root) + .unwrap_or_else(|| "main.tex".to_string()); // Detect bibliography: first .bib file found let bib = detect_bib(&root); From d74155df6b4778aed2e1b7d65c1a45edc3e8f20c Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:44:41 -0500 Subject: [PATCH 07/15] refactor: use symlinks for asset dirs in build/ (fallback to copy on Windows) --- src/diagrams/mod.rs | 72 +++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index bfd095a..fd30736 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -163,39 +163,67 @@ fn resolve_tex(root: &Path, input: &str) -> PathBuf { if p.extension().is_some() { p } else { p.with_extension("tex") } } -/// Mirror non-.tex asset files into build/ so tectonic resolves relative paths. -/// Skips the build/ directory itself to avoid recursion. +/// 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 walkdir::WalkDir::new(root) - .into_iter() - .filter_map(|e| e.ok()) - { + 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 build/ itself - if path.starts_with(build_dir) { - continue; - } - // Skip hidden dirs (.git, etc.) - if path.components().any(|c| { - c.as_os_str().to_string_lossy().starts_with('.') - }) { + // Skip hidden, build/, and .tex files at root level + if name_str.starts_with('.') || path == build_dir { continue; } - if path.is_file() { + 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(""); - // Only mirror asset files, not .tex (those are handled separately) if ext == "tex" { continue; } - let rel = path.strip_prefix(root).unwrap_or(path); - let dest = build_dir.join(rel); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent)?; + if !dest.exists() { + std::fs::copy(&path, &dest)?; } - // Always copy — ensures assets stay in sync with source - 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(()) From 84348438eb1d36ad2570b1f6e7307cc3d41172b9 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:45:41 -0500 Subject: [PATCH 08/15] chore: bump version to 0.2.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dd56c5a..99ff111 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "texforge" -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.75" description = "Self-contained LaTeX to PDF compiler CLI" From 70742dfbe1f761c6b90e37c55e1dd081d1ccac04 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 1 Apr 2026 00:54:32 -0500 Subject: [PATCH 09/15] test: add unit tests for formatter, linter, new command and diagrams parser --- README.md | 4 ++ src/commands/new.rs | 32 +++++++++- src/diagrams/mod.rs | 27 ++++++++- src/formatter/mod.rs | 44 ++++++++++++++ src/linter/mod.rs | 140 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c9a6c7..db46e88 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ texforge fmt # Build to PDF texforge build + +# Remove build artifacts +texforge clean ``` --- @@ -93,6 +96,7 @@ texforge build | `texforge new -t