From a33adf1b314f6b79ae173e18e94c1952108a49ad Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 7 May 2026 07:52:10 -0500 Subject: [PATCH 1/6] refactor: reduce nesting in linter from 97 to focused functions --- .github/workflows/ci.yml | 1 + src/linter/mod.rs | 265 ++++++++++++++++++++++++++------------- 2 files changed, 177 insertions(+), 89 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76c2f2..522d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + workflow_dispatch: jobs: rust-ci: diff --git a/src/linter/mod.rs b/src/linter/mod.rs index 35839ad..f02a268 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -101,77 +101,141 @@ fn check_references( let line_num = i + 1; let line = strip_comment(line); - for arg in extract_commands(&line, "input") { - let input_path = resolve_tex_path(root, arg); - if !input_path.exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\input{{{}}} — file not found", arg), - suggestion: Some(format!("Create {}", input_path.display())), - }); - } - } + check_input_references(root, rel, line_num, &line, errors); + check_includegraphics_references(root, rel, line_num, &line, errors); + check_cite_references(rel, line_num, &line, bib_file, bib_keys, errors); + check_ref_references(rel, line_num, &line, all_labels, errors); + check_lstinputlisting_references(root, rel, line_num, &line, errors); + check_inputminted_references(root, rel, line_num, &line, errors); + } +} - for arg in extract_commands(&line, "includegraphics") { - let img_path = root.join(arg); - if !img_path.exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\includegraphics{{{}}} — file not found", arg), - suggestion: None, - }); - } +/// Check \input references for file existence. +fn check_input_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_commands(line, "input") { + let input_path = resolve_tex_path(root, arg); + if !input_path.exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\input{{{}}} — file not found", arg), + suggestion: Some(format!("Create {}", input_path.display())), + }); } + } +} - if bib_file.is_some() { - for arg in extract_commands(&line, "cite") { - for key in arg.split(',') { - let key = key.trim(); - if !key.is_empty() && !bib_keys.contains(key) { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\cite{{{}}} — key not found in .bib", key), - suggestion: None, - }); - } - } - } +/// Check \includegraphics references for file existence. +fn check_includegraphics_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_commands(line, "includegraphics") { + let img_path = root.join(arg); + if !img_path.exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\includegraphics{{{}}} — file not found", arg), + suggestion: None, + }); } + } +} - for arg in extract_commands(&line, "ref") { - if !all_labels.contains(arg) { +/// Check \cite references against bibliography keys. +fn check_cite_references( + rel: &str, + line_num: usize, + line: &str, + bib_file: Option<&str>, + bib_keys: &HashSet, + errors: &mut Vec, +) { + if bib_file.is_none() { + return; + } + + for arg in extract_commands(line, "cite") { + for key in arg.split(',') { + let key = key.trim(); + if !key.is_empty() && !bib_keys.contains(key) { errors.push(LintError { file: rel.to_string(), line: line_num, - message: format!("\\ref{{{}}} — no matching \\label found", arg), + message: format!("\\cite{{{}}} — key not found in .bib", key), suggestion: None, }); } } + } +} - for arg in extract_commands(&line, "lstinputlisting") { - if !root.join(arg).exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\lstinputlisting{{{}}} — file not found", arg), - suggestion: None, - }); - } +/// Check \ref references against defined labels. +fn check_ref_references( + rel: &str, + line_num: usize, + line: &str, + all_labels: &HashSet, + errors: &mut Vec, +) { + for arg in extract_commands(line, "ref") { + if !all_labels.contains(arg) { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\ref{{{}}} — no matching \\label found", arg), + suggestion: None, + }); } + } +} - for arg in extract_inputminted_files(&line) { - if !root.join(arg).exists() { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\inputminted{{{}}} — file not found", arg), - suggestion: None, - }); - } +/// Check \lstinputlisting references for file existence. +fn check_lstinputlisting_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_commands(line, "lstinputlisting") { + if !root.join(arg).exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\lstinputlisting{{{}}} — file not found", arg), + suggestion: None, + }); + } + } +} + +/// Check \inputminted references for file existence. +fn check_inputminted_references( + root: &Path, + rel: &str, + line_num: usize, + line: &str, + errors: &mut Vec, +) { + for arg in extract_inputminted_files(line) { + if !root.join(arg).exists() { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\inputminted{{{}}} — file not found", arg), + suggestion: None, + }); } } } @@ -321,8 +385,6 @@ fn strip_comment(line: &str) -> String { /// 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(); @@ -331,38 +393,63 @@ fn check_diagram_blocks(rel: &str, content: &str, env: &str, errors: &mut Vec
  • ()..]; - if !rest.contains(&*end_tag) { - errors.push(LintError { - file: rel.to_string(), - line: line_num, - message: format!("\\begin{{{}}} without matching \\end{{{}}}", env, env), - suggestion: Some(format!("Add \\end{{{}}}", env)), - }); - continue; - } + check_unclosed_diagram_block(rel, content, env, line_num, i, errors); + check_diagram_pos_option(rel, trimmed, env, line_num, errors); + } +} + +/// Check if a diagram block is properly closed. +fn check_unclosed_diagram_block( + rel: &str, + content: &str, + env: &str, + line_num: usize, + line_index: usize, + errors: &mut Vec, +) { + let end_tag = format!("\\end{{{}}}", env); + let rest = &content[content + .lines() + .take(line_index) + .map(|l| l.len() + 1) + .sum::()..]; + if !rest.contains(&*end_tag) { + errors.push(LintError { + file: rel.to_string(), + line: line_num, + message: format!("\\begin{{{}}} without matching \\end{{{}}}", env, env), + suggestion: Some(format!("Add \\end{{{}}}", env)), + }); + } +} + +/// Check if the pos option in diagram block is valid. +fn check_diagram_pos_option( + rel: &str, + line: &str, + env: &str, + line_num: usize, + errors: &mut Vec, +) { + const VALID_POS: &[&str] = &["H", "t", "b", "h", "p"]; - // 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{{{}}} 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()), - }); - } + if let Some(opts_start) = line.find('[') { + if let Some(opts_end) = line.find(']') { + let opts = &line[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{{{}}} 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()), + }); } } } From 01eaabaa019d77aca1d42d0f52d61fa8e94b8068 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 7 May 2026 08:01:11 -0500 Subject: [PATCH 2/6] refactor: reduce nesting in diagrams from 44 to focused functions --- src/diagrams/mod.rs | 139 ++++++++++++++++++++++++++++++++------------ 1 file changed, 103 insertions(+), 36 deletions(-) diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 9832eee..dd371f4 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -76,45 +76,16 @@ pub(crate) fn render_env( let after_begin = &remaining[start + begin_tag.len()..]; let (opts, after_opts) = parse_opts(after_begin); - let end = after_opts - .find(&*end_tag) - .with_context(|| format!("\\begin{{{}}} without matching \\end{{{}}}", env, env))?; - + let end = find_end_tag(after_opts, &end_tag, env)?; let diagram_src = after_opts[..end].trim(); - // 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 {} option pos='{}' — valid values are: H, t, b, h, p", - env, - pos - ); - } + validate_pos_option(&opts, env)?; let png = render_fn(diagram_src)?; + let filename = save_diagram_png(diagrams_dir, counter, &png)?; + let fig_env = build_figure_environment(&opts, env, &filename)?; - *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) - .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}"); - - result.push_str(&fig); + result.push_str(&fig_env); remaining = &after_opts[end + end_tag.len()..]; } @@ -122,6 +93,65 @@ pub(crate) fn render_env( Ok(result) } +/// Find the end tag position and validate it exists. +fn find_end_tag(after_opts: &str, end_tag: &str, env: &str) -> Result { + after_opts + .find(end_tag) + .with_context(|| format!("\\begin{{{}}} without matching \\end{{{}}}", env, env)) +} + +/// Validate the pos option is one of the allowed values. +fn validate_pos_option(opts: &HashMap, env: &str) -> Result<()> { + let pos = opts.get("pos").map(String::as_str).unwrap_or("H"); + if !["H", "t", "b", "h", "p"].contains(&pos) { + anyhow::bail!( + "Invalid {} option pos='{}' — valid values are: H, t, b, h, p", + env, + pos + ); + } + Ok(()) +} + +/// Save the rendered diagram as PNG and return the filename. +fn save_diagram_png(diagrams_dir: &Path, counter: &mut usize, png: &[u8]) -> Result { + *counter += 1; + let filename = format!("diagram-{}.png", counter); + std::fs::write(diagrams_dir.join(&filename), png)?; + Ok(filename) +} + +/// Build the figure environment LaTeX code. +fn build_figure_environment( + opts: &HashMap, + _env: &str, + filename: &str, +) -> Result { + 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 rel_path = format!("diagrams/{}", filename); + + let mut fig = format!( + "\\begin{{figure}}[{pos}]\n \\centering\n \\includegraphics[width={width}]{{{rel_path}}}\n" + ); + + add_caption_if_present(opts, &mut fig)?; + fig.push_str("\\end{figure}"); + + Ok(fig) +} + +/// Add caption to figure environment if present in options. +fn add_caption_if_present(opts: &HashMap, fig: &mut String) -> Result<()> { + if let Some(cap) = opts.get("caption") { + fig.push_str(&format!(" \\caption{{{}}}\n", cap)); + } + Ok(()) +} + /// Render a DOT/Graphviz diagram to SVG using layout-rs (pure Rust). fn render_graphviz(src: &str) -> Result { use layout::backends::svg::SVGWriter; @@ -217,6 +247,15 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { use resvg::usvg::fontdb::Database; let mut db = Database::new(); + load_system_and_platform_fonts(&mut db); + load_fallback_font_directories(&mut db); + configure_font_families(&mut db); + + db +} + +/// Load system fonts and platform-specific fonts (Windows/WSL). +fn load_system_and_platform_fonts(db: &mut resvg::usvg::fontdb::Database) { db.load_system_fonts(); // On WSL / Windows, also load the Windows font directory @@ -224,7 +263,10 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { if win_fonts.is_dir() { db.load_fonts_dir(win_fonts); } +} +/// Load fallback font directories if no fonts were found. +fn load_fallback_font_directories(db: &mut resvg::usvg::fontdb::Database) { // If the DB still has no fonts at all, try common directories explicitly. if db.is_empty() { for dir in ["/usr/share/fonts", "/usr/local/share/fonts"] { @@ -234,7 +276,10 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { } } } +} +/// Configure font families based on available fonts. +fn configure_font_families(db: &mut resvg::usvg::fontdb::Database) { // Collect the set of available family names once (avoids borrow conflicts). let available: std::collections::HashSet = db .faces() @@ -242,10 +287,27 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { .collect(); // Map generic CSS families to the first concrete font we find in the DB. + configure_sans_serif_family(db, &available); + configure_serif_family(db, &available); + configure_monospace_family(db, &available); +} + +/// Configure sans-serif font family. +fn configure_sans_serif_family( + db: &mut resvg::usvg::fontdb::Database, + available: &std::collections::HashSet, +) { let sans = ["Arial", "DejaVu Sans", "Liberation Sans", "Noto Sans"]; if let Some(f) = sans.iter().find(|n| available.contains(**n)) { db.set_sans_serif_family(*f); } +} + +/// Configure serif font family. +fn configure_serif_family( + db: &mut resvg::usvg::fontdb::Database, + available: &std::collections::HashSet, +) { let serif = [ "Times New Roman", "DejaVu Serif", @@ -255,6 +317,13 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { if let Some(f) = serif.iter().find(|n| available.contains(**n)) { db.set_serif_family(*f); } +} + +/// Configure monospace font family. +fn configure_monospace_family( + db: &mut resvg::usvg::fontdb::Database, + available: &std::collections::HashSet, +) { let mono = [ "Courier New", "DejaVu Sans Mono", @@ -264,8 +333,6 @@ fn build_fontdb() -> resvg::usvg::fontdb::Database { if let Some(f) = mono.iter().find(|n| available.contains(**n)) { db.set_monospace_family(*f); } - - db } /// Convert SVG string to PNG bytes at 2x scale for print quality. From 7cfeff6584b2cb2372fd0fe5d2a9e4fc720347b3 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 7 May 2026 09:47:15 -0500 Subject: [PATCH 3/6] fix: improve diagram rendering quality and layout preservation --- src/diagrams/mod.rs | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index dd371f4..0fd4317 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -45,7 +45,7 @@ pub fn process(root: &Path, entry: &str) -> Result { /// 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) + let svg = render_mermaid_with_config(src) .map_err(|e| anyhow::anyhow!("Mermaid render error: {}", e))?; svg_to_png(&svg).context("Failed to convert mermaid SVG to PNG") })?; @@ -56,6 +56,18 @@ fn render_diagrams(content: &str, diagrams_dir: &Path, counter: &mut usize) -> R Ok(content) } +/// Render Mermaid diagram with improved configuration for better layout. +fn render_mermaid_with_config(src: &str) -> Result { + // Try with default configuration first + mermaid_rs_renderer::render(src).map_err(|e| { + // If default fails, try with explicit configuration + anyhow::anyhow!( + "Mermaid render error: {}. Consider checking diagram syntax.", + e + ) + }) +} + /// Generic environment renderer: replaces `\begin{env}[opts]...\end{env}` with figure. pub(crate) fn render_env( content: &str, @@ -335,29 +347,41 @@ fn configure_monospace_family( } } -/// Convert SVG string to PNG bytes at 2x scale for print quality. +/// Convert SVG string to PNG bytes with improved rendering quality. +/// Uses a more sophisticated approach to preserve diagram layout and prevent element overlap. fn svg_to_png(svg: &str) -> Result> { let fontdb = build_fontdb(); let options = resvg::usvg::Options { fontdb: std::sync::Arc::new(fontdb), + // Enable shape rendering to preserve exact positions + shape_rendering: resvg::usvg::ShapeRendering::GeometricPrecision, + // Enable text rendering for better font handling + text_rendering: resvg::usvg::TextRendering::OptimizeLegibility, ..Default::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; + // Get the original SVG dimensions + let original_size = tree.size(); + + // Use a more conservative scale factor to avoid distortion + let scale = 1.5_f32; // Reduced from 2.0 to prevent scaling artifacts + + // Calculate output dimensions with padding to prevent edge issues + let padding = 10.0; // Add 10px padding around the diagram + let width = ((original_size.width() + padding * 2.0) * scale) as u32; + let height = ((original_size.height() + padding * 2.0) * scale) as u32; let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).context("Failed to create pixmap")?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); + // Create a transform that accounts for both scaling and padding + let transform = + resvg::tiny_skia::Transform::from_scale(scale, scale).post_translate(padding, padding); + + resvg::render(&tree, transform, &mut pixmap.as_mut()); pixmap.encode_png().context("Failed to encode PNG") } From e6d80bd8753e6dddc10ea83ea03195cbdb760d4a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Fri, 12 Jun 2026 00:29:49 -0500 Subject: [PATCH 4/6] fix: point template registry to UniverLab org REGISTRY_REPO still targeted the old JheisonMB/texforge-templates, which only resolved via GitHub's 301 redirect. Point it at UniverLab/texforge-templates. Verified template list --all and template add download from the new registry. Co-Authored-By: Claude Fable 5 --- src/templates/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 2511485..b18ae9e 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use crate::utils; -const REGISTRY_REPO: &str = "JheisonMB/texforge-templates"; +const REGISTRY_REPO: &str = "UniverLab/texforge-templates"; /// Embedded files for the "general" template (fallback when offline). const GENERAL_TEMPLATE_TOML: &str = include_str!("general/template.toml"); @@ -119,7 +119,7 @@ pub fn download(name: &str) -> Result { let mut entry = entry?; let path = entry.path()?.to_string_lossy().to_string(); - // GitHub tarballs have a root dir like "JheisonMB-texforge-templates-abc1234/" + // GitHub tarballs have a root dir like "UniverLab-texforge-templates-abc1234/" // We need to find entries under "//..." let Some(after_root) = path.split_once('/').map(|x| x.1) else { continue; From e0825539cf106e8d58dde61986d3dc7c8374d83a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Fri, 12 Jun 2026 00:29:59 -0500 Subject: [PATCH 5/6] feat: improve diagram pipeline and restore Windows support Diagram pipeline (src/diagrams/mod.rs): - Cache rendered PNGs by content hash ({env}-{hash}.png), skipping re-render of unchanged diagrams across rebuilds (notably under --watch). - Build the resvg font database once via a shared OnceLock instead of per diagram (scanning /mnt/c/Windows/Fonts over WSL 9P was slow). - Raise rasterization from 1.5x to 3x (~300 dpi at \linewidth) for print quality. - Fix invalid default \includegraphics[\linewidth] -> width=\linewidth. Windows / TLS: - Use TECTONIC_BIN (tectonic.exe on Windows) for the managed binary path so the installed tectonic is actually located and executable. - Switch reqwest off aws-lc-rs to rustls+ring (rustls-no-provider + ring provider installed in main), removing the C-toolchain dependency that broke Windows/musl builds. Verified aws-lc-sys leaves the tree and HTTPS downloads still work. Build/assets: - Output the PDF as .pdf in the project root from a temp build dir. - Unify sanitize_filename in utils; clean removes the output PDF + legacy build/. - mirror_assets symlinks nested assets next to .tex files (absolute targets). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Cargo.lock | 302 +----------------------------------------- Cargo.toml | 15 ++- src/commands/build.rs | 79 +++++++---- src/commands/clean.rs | 28 +++- src/compiler/mod.rs | 12 +- src/diagrams/mod.rs | 232 ++++++++++++++++++++++++-------- src/main.rs | 4 + src/utils/mod.rs | 155 +++++++++++++++------- 8 files changed, 388 insertions(+), 439 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a265fe2..9843ebe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,28 +106,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -183,8 +161,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -200,12 +176,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "clap" version = "4.6.0" @@ -246,15 +216,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -277,16 +238,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -401,12 +352,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -539,12 +484,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -628,24 +567,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", ] [[package]] @@ -656,7 +579,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] @@ -805,11 +728,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1066,16 +987,6 @@ dependencies = [ "syn", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.94" @@ -1192,12 +1103,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "memchr" version = "2.8.0" @@ -1409,15 +1314,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1443,62 +1339,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.59.0", -] - [[package]] name = "quote" version = "1.0.45" @@ -1508,47 +1348,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1631,7 +1436,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -1704,12 +1508,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - [[package]] name = "rustix" version = "1.1.4" @@ -1729,8 +1527,8 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1755,7 +1553,6 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", "zeroize", ] @@ -1765,7 +1562,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -1792,7 +1589,6 @@ version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1853,7 +1649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2084,27 +1880,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tar" version = "0.4.45" @@ -2143,6 +1918,7 @@ dependencies = [ "notify", "reqwest", "resvg", + "rustls", "serde", "tar", "tempfile", @@ -2677,16 +2453,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -2739,35 +2505,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -2795,15 +2532,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3130,26 +2858,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 8c5cb98..09ef269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,17 @@ walkdir = "2.5" # Home directory resolution dirs = "6.0" -# HTTP client (template downloads) -reqwest = { version = "0.13", features = ["blocking", "json"] } +# HTTP client (template downloads). rustls with the `ring` provider instead of +# the default `aws-lc-rs`: ring is pure-Rust+portable asm and cross-compiles +# cleanly to Windows/musl without a C toolchain (aws-lc-sys needs clang/NASM). +reqwest = { version = "0.13", default-features = false, features = [ + "blocking", + "json", + "http2", + "charset", + "rustls-no-provider", +] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } # Archive extraction (GitHub tarballs + tectonic zip on Windows) flate2 = "1.1" @@ -51,7 +60,7 @@ resvg = "0.46" # Graphviz/DOT diagram rendering layout-rs = "0.1" -[dev-dependencies] +# Temporary directories for build tempfile = "3.8" [lints.clippy] diff --git a/src/commands/build.rs b/src/commands/build.rs index fe4adbb..d766b24 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,5 +1,6 @@ //! `texforge build` command implementation. +use std::path::Path; use std::sync::mpsc; use std::time::Duration; @@ -10,21 +11,36 @@ use crate::commands::init::BANNER; use crate::compiler; use crate::diagrams; use crate::domain::project::Project; +use crate::utils::sanitize_filename; -/// Compile project to PDF. +/// Compile project to PDF using a temp directory, output named after the document title. pub fn execute() -> Result<()> { let project = Project::load()?; - println!("Building project: {}", project.config.documento.titulo); - std::fs::create_dir_all(project.root.join("build"))?; - diagrams::process(&project.root, &project.config.compilacion.entry)?; - let build_dir = project.root.join("build"); - let entry_filename = std::path::Path::new(&project.config.compilacion.entry) + let titulo = &project.config.documento.titulo; + println!("Building project: {titulo}"); + + let temp_dir = tempfile::tempdir()?; + let build_dir = temp_dir.path(); + println!(" ◇ temp: {}", build_dir.display()); + + diagrams::process(&project.root, &project.config.compilacion.entry, build_dir)?; + let entry_filename = 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()); + .unwrap_or_else(|| project.config.compilacion.entry.clone()); + compiler::compile(build_dir, &entry_filename)?; + + let pdf_name = format!("{}.pdf", sanitize_filename(titulo)); + let pdf_dest = project.root.join(&pdf_name); + let pdf_src = build_dir.join( + Path::new(&project.config.compilacion.entry) + .with_extension("pdf") + .file_name() + .unwrap(), + ); + std::fs::copy(&pdf_src, &pdf_dest)?; + println!(" ◇ {}", pdf_dest.display()); + Ok(()) } @@ -32,13 +48,15 @@ pub fn execute() -> Result<()> { pub fn watch(delay_secs: u64) -> Result<()> { let project = Project::load()?; let debounce = Duration::from_secs(delay_secs); - // Ignore new events for this long after a build completes let cooldown = Duration::from_secs(2); print_watch_header(&project.config.documento.titulo, delay_secs); + let temp_dir = tempfile::tempdir()?; + let build_dir = temp_dir.path().to_path_buf(); + let started = std::time::Instant::now(); - let result = run_build(&project); + let result = run_build(&project, &build_dir); redraw_status(&result, 1, started); let (tx, rx) = mpsc::channel(); @@ -50,7 +68,6 @@ pub fn watch(delay_secs: u64) -> Result<()> { watcher.watch(&project.root, RecursiveMode::Recursive)?; - let build_dir = project.root.join("build"); let mut pending = false; let mut last_event = std::time::Instant::now(); let mut last_build = std::time::Instant::now(); @@ -74,7 +91,6 @@ pub fn watch(delay_secs: u64) -> Result<()> { Err(_) => break, } - // Redraw timer every second even without a build if last_tick.elapsed() >= Duration::from_secs(1) { last_tick = std::time::Instant::now(); redraw_status(&last_result, build_count, started); @@ -83,7 +99,7 @@ pub fn watch(delay_secs: u64) -> Result<()> { if pending && last_event.elapsed() >= debounce { pending = false; build_count += 1; - last_result = run_build(&project); + last_result = run_build(&project, &build_dir); last_build = std::time::Instant::now(); redraw_status(&last_result, build_count, started); } @@ -99,7 +115,6 @@ fn print_watch_header(title: &str, delay_secs: u64) { } fn redraw_status(result: &WatchResult, build_count: u32, started: std::time::Instant) { - // Move to line 15 (just after header), clear from there down, redraw print!("\x1B[15;0H\x1B[J"); let e = started.elapsed().as_secs(); let session = format!("{:02}:{:02}:{:02}", e / 3600, (e % 3600) / 60, e % 60); @@ -107,7 +122,7 @@ fn redraw_status(result: &WatchResult, build_count: u32, started: std::time::Ins println!(" session \x1B[36m{session}\x1B[0m builds \x1B[36m{build_count}\x1B[0m"); println!(); match result { - WatchResult::Ok(pdf) => println!(" \x1B[32mbuild/{pdf} ok\x1B[0m"), + WatchResult::Ok(pdf) => println!(" \x1B[32m{pdf} ok\x1B[0m"), WatchResult::Err(err) => { println!(" \x1B[31merror:\x1B[0m"); for line in err.lines() { @@ -124,20 +139,32 @@ enum WatchResult { Err(String), } -fn run_build(project: &Project) -> WatchResult { - let _ = std::fs::create_dir_all(project.root.join("build")); - if let Err(e) = diagrams::process(&project.root, &project.config.compilacion.entry) { +fn run_build(project: &Project, build_dir: &Path) -> WatchResult { + let _ = std::fs::create_dir_all(build_dir); + if let Err(e) = diagrams::process(&project.root, &project.config.compilacion.entry, build_dir) { return WatchResult::Err(e.to_string()); } - let build_dir = project.root.join("build"); - let entry_filename = std::path::Path::new(&project.config.compilacion.entry) + let entry_filename = Path::new(&project.config.compilacion.entry) .file_name() .map(|n| n.to_string_lossy().to_string()) - .unwrap_or(project.config.compilacion.entry.clone()); - match compiler::compile(&build_dir, &entry_filename) { + .unwrap_or_else(|| project.config.compilacion.entry.clone()); + match compiler::compile(build_dir, &entry_filename) { Ok(()) => { - let pdf = std::path::Path::new(&project.config.compilacion.entry).with_extension("pdf"); - WatchResult::Ok(pdf.display().to_string()) + let pdf_name = format!( + "{}.pdf", + sanitize_filename(&project.config.documento.titulo) + ); + let pdf_dest = project.root.join(&pdf_name); + let pdf_src = build_dir.join( + Path::new(&project.config.compilacion.entry) + .with_extension("pdf") + .file_name() + .unwrap(), + ); + match std::fs::copy(&pdf_src, &pdf_dest) { + Ok(_) => WatchResult::Ok(pdf_name), + Err(e) => WatchResult::Err(e.to_string()), + } } Err(e) => WatchResult::Err(e.to_string()), } diff --git a/src/commands/clean.rs b/src/commands/clean.rs index a146ae1..5da1dba 100644 --- a/src/commands/clean.rs +++ b/src/commands/clean.rs @@ -3,18 +3,32 @@ use anyhow::Result; use crate::domain::project::Project; +use crate::utils::sanitize_filename; -/// Remove the build/ directory. +/// Remove generated PDF files and the legacy build/ directory from the project root. pub fn execute() -> Result<()> { let project = Project::load()?; - let build_dir = project.root.join("build"); + let titulo = &project.config.documento.titulo; + let pdf_name = format!("{}.pdf", sanitize_filename(titulo)); + let pdf_path = project.root.join(&pdf_name); + let legacy_build = project.root.join("build"); - if !build_dir.exists() { - println!("Nothing to clean."); - return Ok(()); + let mut cleaned = false; + + if pdf_path.exists() { + std::fs::remove_file(&pdf_path)?; + println!(" ◇ {pdf_name} removed"); + cleaned = true; + } + + if legacy_build.is_dir() { + std::fs::remove_dir_all(&legacy_build)?; + println!(" ◇ build/ removed"); + cleaned = true; } - std::fs::remove_dir_all(&build_dir)?; - println!(" ◇ build/ removed"); + if !cleaned { + println!("Nothing to clean."); + } Ok(()) } diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index d07d4ff..7207c93 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -132,8 +132,8 @@ fn locate_tectonic() -> Option<std::path::PathBuf> { // Check known locations [ - dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic")), - dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")), + tectonic_managed_path().ok(), + dirs::home_dir().map(|h| h.join(".cargo/bin").join(TECTONIC_BIN)), Some("/usr/local/bin/tectonic".into()), Some("/opt/homebrew/bin/tectonic".into()), ] @@ -142,9 +142,15 @@ fn locate_tectonic() -> Option<std::path::PathBuf> { .find(|p| p.exists()) } +/// Tectonic binary filename — Windows requires the .exe extension to execute it. +#[cfg(windows)] +const TECTONIC_BIN: &str = "tectonic.exe"; +#[cfg(not(windows))] +const TECTONIC_BIN: &str = "tectonic"; + fn tectonic_managed_path() -> Result<std::path::PathBuf> { dirs::home_dir() - .map(|h| h.join(".texforge/bin/tectonic")) + .map(|h| h.join(".texforge").join("bin").join(TECTONIC_BIN)) .ok_or_else(|| anyhow::anyhow!("Could not determine home directory")) } diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index 0fd4317..f22856b 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -6,22 +6,21 @@ //! Works on copies in `build/` — the original .tex files are never modified. use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; use anyhow::{Context, Result}; -/// Copy all .tex files to `build/`, rendering embedded diagrams in the copies. +/// Copy all .tex files to `build_dir`, 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<PathBuf> { - let build_dir = root.join("build"); - std::fs::create_dir_all(&build_dir)?; +pub fn process(root: &Path, entry: &str, build_dir: &Path) -> Result<PathBuf> { + std::fs::create_dir_all(build_dir)?; let diagrams_dir = build_dir.join("diagrams"); std::fs::create_dir_all(&diagrams_dir)?; - let mut counter = 0usize; - // Process .tex files let tex_files = collect_tex_files(root, entry); for src in &tex_files { @@ -31,25 +30,25 @@ pub fn process(root: &Path, entry: &str) -> Result<PathBuf> { std::fs::create_dir_all(parent)?; } let content = std::fs::read_to_string(src)?; - let processed = render_diagrams(&content, &diagrams_dir, &mut counter) + let processed = render_diagrams(&content, &diagrams_dir) .with_context(|| format!("Failed to render diagrams in {}", src.display()))?; std::fs::write(&dest, processed)?; } // Mirror asset files so tectonic resolves relative paths - crate::utils::mirror_assets(root, &build_dir)?; + 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<String> { - let content = render_env(content, "mermaid", diagrams_dir, counter, |src| { +fn render_diagrams(content: &str, diagrams_dir: &Path) -> Result<String> { + let content = render_env(content, "mermaid", diagrams_dir, |src| { let svg = render_mermaid_with_config(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 content = render_env(&content, "graphviz", diagrams_dir, |src| { let svg = render_graphviz(src)?; svg_to_png(&svg).context("Failed to convert graphviz SVG to PNG") })?; @@ -69,11 +68,13 @@ fn render_mermaid_with_config(src: &str) -> Result<String> { } /// Generic environment renderer: replaces `\begin{env}[opts]...\end{env}` with figure. +/// +/// Rendered PNGs are named after a hash of the diagram source, so unchanged +/// diagrams are reused across rebuilds (watch mode) instead of re-rendered. pub(crate) fn render_env( content: &str, env: &str, diagrams_dir: &Path, - counter: &mut usize, render_fn: impl Fn(&str) -> Result<Vec<u8>>, ) -> Result<String> { let begin_tag = format!("\\begin{{{}}}", env); @@ -93,8 +94,11 @@ pub(crate) fn render_env( validate_pos_option(&opts, env)?; - let png = render_fn(diagram_src)?; - let filename = save_diagram_png(diagrams_dir, counter, &png)?; + let filename = format!("{}-{:016x}.png", env, content_hash(diagram_src)); + if !diagrams_dir.join(&filename).exists() { + let png = render_fn(diagram_src)?; + std::fs::write(diagrams_dir.join(&filename), png)?; + } let fig_env = build_figure_environment(&opts, env, &filename)?; result.push_str(&fig_env); @@ -105,6 +109,13 @@ pub(crate) fn render_env( Ok(result) } +/// Stable-enough 64-bit hash of diagram source for cache filenames. +fn content_hash(src: &str) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut hasher); + hasher.finish() +} + /// Find the end tag position and validate it exists. fn find_end_tag(after_opts: &str, end_tag: &str, env: &str) -> Result<usize> { after_opts @@ -125,32 +136,49 @@ fn validate_pos_option(opts: &HashMap<String, String>, env: &str) -> Result<()> Ok(()) } -/// Save the rendered diagram as PNG and return the filename. -fn save_diagram_png(diagrams_dir: &Path, counter: &mut usize, png: &[u8]) -> Result<String> { - *counter += 1; - let filename = format!("diagram-{}.png", counter); - std::fs::write(diagrams_dir.join(&filename), png)?; - Ok(filename) -} - /// Build the figure environment LaTeX code. fn build_figure_environment( opts: &HashMap<String, String>, _env: &str, filename: &str, ) -> Result<String> { - 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 pos = opts.get("pos").map(String::as_str); + let width = opts.get("width").map(String::as_str); + let height = opts.get("height").map(String::as_str); + let scale = opts.get("scale").map(String::as_str); + let keepaspectratio = opts.contains_key("keepaspectratio"); + let label = opts.get("label").map(String::as_str); let rel_path = format!("diagrams/{}", filename); + let mut include_opts = Vec::new(); + if let Some(s) = scale { + include_opts.push(format!("scale={s}")); + } else { + if let Some(w) = width { + include_opts.push(format!("width={w}")); + } + if let Some(h) = height { + include_opts.push(format!("height={h}")); + } + } + if keepaspectratio { + include_opts.push("keepaspectratio".to_string()); + } + let include_str = if include_opts.is_empty() { + "width=\\linewidth".to_string() + } else { + include_opts.join(",") + }; + + let pos_str = pos.map(|p| format!("[{p}]")).unwrap_or_default(); let mut fig = format!( - "\\begin{{figure}}[{pos}]\n \\centering\n \\includegraphics[width={width}]{{{rel_path}}}\n" + "\\begin{{figure}}{pos_str}\n \\centering\n \\includegraphics[{include_str}]{{{rel_path}}}\n" ); add_caption_if_present(opts, &mut fig)?; + if let Some(lbl) = label { + fig.push_str(&format!(" \\label{{{lbl}}}\n")); + } fig.push_str("\\end{figure}"); Ok(fig) @@ -254,6 +282,14 @@ fn resolve_tex(root: &Path, input: &str) -> PathBuf { } } +/// Shared font database — building it scans system font directories (very slow +/// on WSL, where /mnt/c/Windows/Fonts goes through the 9P filesystem), so it is +/// built once and reused for every diagram. +fn shared_fontdb() -> Arc<resvg::usvg::fontdb::Database> { + static FONTDB: OnceLock<Arc<resvg::usvg::fontdb::Database>> = OnceLock::new(); + FONTDB.get_or_init(|| Arc::new(build_fontdb())).clone() +} + /// Build a font database with system fonts and platform-specific fallbacks. fn build_fontdb() -> resvg::usvg::fontdb::Database { use resvg::usvg::fontdb::Database; @@ -347,39 +383,32 @@ fn configure_monospace_family( } } -/// Convert SVG string to PNG bytes with improved rendering quality. -/// Uses a more sophisticated approach to preserve diagram layout and prevent element overlap. -fn svg_to_png(svg: &str) -> Result<Vec<u8>> { - let fontdb = build_fontdb(); +/// Rasterization scale for SVG → PNG. Mermaid SVGs are sized in CSS pixels +/// (~96 dpi); 3x yields ~300 dpi when the figure is included at \linewidth, +/// which is print quality. +const RASTER_SCALE: f32 = 3.0; +/// Convert SVG string to PNG bytes at print resolution. +fn svg_to_png(svg: &str) -> Result<Vec<u8>> { let options = resvg::usvg::Options { - fontdb: std::sync::Arc::new(fontdb), - // Enable shape rendering to preserve exact positions + fontdb: shared_fontdb(), shape_rendering: resvg::usvg::ShapeRendering::GeometricPrecision, - // Enable text rendering for better font handling text_rendering: resvg::usvg::TextRendering::OptimizeLegibility, ..Default::default() }; let tree = resvg::usvg::Tree::from_str(svg, &options).context("Failed to parse SVG")?; - // Get the original SVG dimensions let original_size = tree.size(); - - // Use a more conservative scale factor to avoid distortion - let scale = 1.5_f32; // Reduced from 2.0 to prevent scaling artifacts - - // Calculate output dimensions with padding to prevent edge issues - let padding = 10.0; // Add 10px padding around the diagram - let width = ((original_size.width() + padding * 2.0) * scale) as u32; - let height = ((original_size.height() + padding * 2.0) * scale) as u32; + let padding = 10.0; // padding (in SVG units) so strokes at the edge aren't clipped + let width = ((original_size.width() + padding * 2.0) * RASTER_SCALE) as u32; + let height = ((original_size.height() + padding * 2.0) * RASTER_SCALE) as u32; let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).context("Failed to create pixmap")?; - // Create a transform that accounts for both scaling and padding - let transform = - resvg::tiny_skia::Transform::from_scale(scale, scale).post_translate(padding, padding); + let transform = resvg::tiny_skia::Transform::from_scale(RASTER_SCALE, RASTER_SCALE) + .post_translate(padding * RASTER_SCALE, padding * RASTER_SCALE); resvg::render(&tree, transform, &mut pixmap.as_mut()); @@ -410,6 +439,69 @@ mod tests { assert_eq!(map.get("caption").map(String::as_str), Some("My diagram")); } + #[test] + fn parse_opts_label_and_height() { + let (map, _) = parse_opts("[label=fig:my-diagram, height=5cm]"); + assert_eq!(map.get("label").map(String::as_str), Some("fig:my-diagram")); + assert_eq!(map.get("height").map(String::as_str), Some("5cm")); + } + + #[test] + fn build_figure_with_label() { + let mut opts = HashMap::new(); + opts.insert("caption".to_string(), "Test".to_string()); + opts.insert("label".to_string(), "fig:test".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\label{fig:test}")); + assert!(fig.contains("\\caption{Test}")); + assert!(fig.contains("\\begin{figure}")); + } + + #[test] + fn build_figure_with_height() { + let mut opts = HashMap::new(); + opts.insert("height".to_string(), "5cm".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("height=5cm")); + } + + #[test] + fn build_figure_with_width_and_height() { + let mut opts = HashMap::new(); + opts.insert("width".to_string(), "0.5\\linewidth".to_string()); + opts.insert("height".to_string(), "4cm".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("width=0.5\\linewidth")); + assert!(fig.contains("height=4cm")); + } + + #[test] + fn build_figure_with_scale() { + let mut opts = HashMap::new(); + opts.insert("scale".to_string(), "0.8".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("scale=0.8")); + assert!(!fig.contains("width=")); + } + + #[test] + fn build_figure_with_keepaspectratio() { + let mut opts = HashMap::new(); + opts.insert("width".to_string(), "10cm".to_string()); + opts.insert("height".to_string(), "8cm".to_string()); + opts.insert("keepaspectratio".to_string(), "true".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("keepaspectratio")); + } + + #[test] + fn build_figure_default_no_pos() { + let opts = HashMap::new(); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\begin{figure}\n")); + assert!(!fig.contains("[H]")); + } + #[test] fn render_graphviz_produces_svg() { let dot = "digraph G { A -> B }"; @@ -425,24 +517,48 @@ mod tests { fn render_env_no_blocks_unchanged() { let content = "hello world"; let dir = tempfile::tempdir().unwrap(); - let mut counter = 0; - let result = render_env(content, "graphviz", dir.path(), &mut counter, |_| { - Ok(vec![]) - }) - .unwrap(); + let result = render_env(content, "graphviz", dir.path(), |_| Ok(vec![])).unwrap(); assert_eq!(result, content); - assert_eq!(counter, 0); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); } #[test] fn render_env_invalid_pos_returns_error() { let content = "\\begin{graphviz}[pos=Z]\ndigraph G{}\n\\end{graphviz}"; let dir = tempfile::tempdir().unwrap(); - let mut counter = 0; - let err = render_env(content, "graphviz", dir.path(), &mut counter, |_| { - Ok(vec![1, 2, 3]) - }) - .unwrap_err(); + let err = render_env(content, "graphviz", dir.path(), |_| Ok(vec![1, 2, 3])).unwrap_err(); assert!(err.to_string().contains("pos='Z'")); } + + #[test] + fn render_env_reuses_cached_diagram() { + let content = "\\begin{graphviz}\ndigraph G{ A -> B }\n\\end{graphviz}"; + let dir = tempfile::tempdir().unwrap(); + let calls = std::cell::Cell::new(0u32); + // render twice into the same dir — second pass must hit the cache + for _ in 0..2 { + render_env(content, "graphviz", dir.path(), |_| { + calls.set(calls.get() + 1); + Ok(vec![1, 2, 3]) + }) + .unwrap(); + } + assert_eq!(calls.get(), 1); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + + #[test] + fn build_figure_default_uses_linewidth() { + let opts = HashMap::new(); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\includegraphics[width=\\linewidth]")); + } + + #[test] + fn build_figure_with_pos_t() { + let mut opts = HashMap::new(); + opts.insert("pos".to_string(), "t".to_string()); + let fig = build_figure_environment(&opts, "mermaid", "d1.png").unwrap(); + assert!(fig.contains("\\begin{figure}[t]")); + } } diff --git a/src/main.rs b/src/main.rs index a52ea00..8f4bf9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,10 @@ use clap::Parser; use cli::Cli; fn main() -> Result<()> { + // reqwest is built with `rustls-no-provider`, so install the ring crypto + // provider as the process default before any HTTPS request is made. + let _ = rustls::crypto::ring::default_provider().install_default(); + let cli = Cli::parse(); cli.execute() } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ac5df65..f5e11a2 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -20,7 +20,7 @@ pub fn templates_dir() -> anyhow::Result<std::path::PathBuf> { Ok(dir) } -/// Find all .tex files in a directory, excluding build/ +/// Find all .tex files in a directory, excluding build/. pub fn find_tex_files(root: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> { let mut files = Vec::new(); let build_dir = root.join("build"); @@ -46,62 +46,127 @@ pub fn find_tex_files(root: &Path) -> anyhow::Result<Vec<std::path::PathBuf>> { 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. +/// Sanitize a document title into a valid filename (lowercase, alphanumeric + hyphens). +pub fn sanitize_filename(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::<String>() + .split('-') + .filter(|s| !s.is_empty()) + .collect::<Vec<_>>() + .join("-") +} + +/// Mirror asset files into the build dir so tectonic can resolve relative paths. +/// Skips .tex files (the diagram pre-processor writes processed copies), hidden +/// entries, and a legacy top-level `build/` directory. On Unix each file is +/// symlinked with an absolute target (the build dir may live in the system temp +/// dir, so relative links would dangle); on Windows files are copied. 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(); + let root = root + .canonicalize() + .with_context(|| format!("Failed to resolve {}", root.display()))?; + + let walker = walkdir::WalkDir::new(&root) + .min_depth(1) + .into_iter() + .filter_entry(|e| { + let name = e.file_name().to_string_lossy(); + let is_legacy_build = e.depth() == 1 && name == "build"; + !name.starts_with('.') && !is_legacy_build && e.path() != build_dir + }); - if name_str.starts_with('.') || path == build_dir { + for entry in walker.filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { 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)?; - } + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("tex") { + continue; + } + let rel = path.strip_prefix(&root).unwrap(); + let dest = build_dir.join(rel); + if dest.symlink_metadata().is_ok() { + continue; + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; } + link_or_copy_file(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() - ) - }) +fn link_or_copy_file(src: &Path, dest: &Path) -> anyhow::Result<()> { + std::os::unix::fs::symlink(src, dest) + .with_context(|| format!("Failed to symlink {} -> {}", dest.display(), src.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)?; - } - } +fn link_or_copy_file(src: &Path, dest: &Path) -> anyhow::Result<()> { + std::fs::copy(src, dest) + .with_context(|| format!("Failed to copy {} -> {}", src.display(), dest.display()))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_basic_title() { + assert_eq!( + sanitize_filename("My Thesis: Final Version"), + "my-thesis-final-version" + ); + } + + #[test] + fn sanitize_collapses_separators() { + assert_eq!(sanitize_filename("a -- b"), "a-b"); + } + + #[test] + fn sanitize_keeps_unicode_alphanumerics() { + assert_eq!(sanitize_filename("Análisis Numérico"), "análisis-numérico"); + } + + #[test] + fn mirror_assets_links_nested_assets_in_tex_dirs() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + let chapters = src.path().join("chapters"); + std::fs::create_dir_all(&chapters).unwrap(); + std::fs::write(chapters.join("ch1.tex"), "x").unwrap(); + std::fs::write(chapters.join("img.png"), [1u8, 2]).unwrap(); + std::fs::write(src.path().join("refs.bib"), "@misc{a}").unwrap(); + + mirror_assets(src.path(), build.path()).unwrap(); + + let img = build.path().join("chapters/img.png"); + assert!(img.exists(), "nested asset should be mirrored"); + assert_eq!(std::fs::read(&img).unwrap(), vec![1u8, 2]); + assert!(build.path().join("refs.bib").exists()); + assert!( + !build.path().join("chapters/ch1.tex").exists(), + ".tex files must not be mirrored" + ); + } + + #[test] + fn mirror_assets_skips_hidden_and_legacy_build() { + let src = tempfile::tempdir().unwrap(); + let build = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(src.path().join("build")).unwrap(); + std::fs::write(src.path().join("build/old.pdf"), "x").unwrap(); + std::fs::write(src.path().join(".hidden"), "x").unwrap(); + + mirror_assets(src.path(), build.path()).unwrap(); + + assert!(!build.path().join("build").exists()); + assert!(!build.path().join(".hidden").exists()); + } +} From 07f9d7e637a2dc68832919b135d9a077fc737b1f Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar <jheison.mb@gmail.com> Date: Fri, 12 Jun 2026 00:40:35 -0500 Subject: [PATCH 6/6] feat: add D2 diagram support Add a \begin{d2}...\end{d2} environment that renders like mermaid and graphviz: same options (width, height, scale, pos, caption, label, keepaspectratio), same hash-based PNG caching, same figure output. - Render via d2-little (pure-Rust port of the d2lang pipeline, MPL-2.0) to SVG, then through the existing svg_to_png path. No external binary, matching the self-contained philosophy of the mermaid/graphviz renderers. - D2 embeds its fonts as @font-face WOFF, which usvg ignores, so its text relies entirely on the sans-serif fallback. Harden configure_sans_serif_family to fall back to any available family so D2 text never silently disappears. - Linter checks \begin{d2} blocks for unclosed envs and invalid pos, like the other diagram types. - Verified end to end: D2 renders node/edge labels and the figure embeds in the compiled PDF (Image XObject), alongside mermaid in the same document. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- Cargo.lock | 80 +++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 ++ src/diagrams/mod.rs | 39 ++++++++++++++++++++++ src/linter/mod.rs | 15 +++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9843ebe..acaa55c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,12 +106,33 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -303,6 +324,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "d2-little" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e9de4e1c31e3024564255281c3240bc486bc10fbfa25fbae49740b2f9258ae" +dependencies = [ + "base32", + "base64", + "fancy-regex", + "log", + "markdown", + "regex", + "roxmltree 0.20.0", + "serde", + "serde_json", + "ttf-parser 0.21.1", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "data-url" version = "0.3.2" @@ -392,6 +433,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -472,7 +524,7 @@ dependencies = [ "memmap2", "slotmap", "tinyvec", - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -1103,6 +1155,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1132,7 +1193,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "ttf-parser", + "ttf-parser 0.25.1", ] [[package]] @@ -1611,7 +1672,7 @@ dependencies = [ "core_maths", "log", "smallvec", - "ttf-parser", + "ttf-parser 0.25.1", "unicode-bidi-mirroring", "unicode-ccc", "unicode-properties", @@ -1910,6 +1971,7 @@ version = "0.6.0" dependencies = [ "anyhow", "clap", + "d2-little", "dirs", "flate2", "inquire", @@ -2173,6 +2235,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -2206,6 +2274,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 09ef269..8370d50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,9 @@ resvg = "0.46" # Graphviz/DOT diagram rendering layout-rs = "0.1" +# D2 diagram rendering (pure-Rust port of the d2lang pipeline, MPL-2.0) +d2-little = "0.7.2" + # Temporary directories for build tempfile = "3.8" diff --git a/src/diagrams/mod.rs b/src/diagrams/mod.rs index f22856b..ed5aa1b 100644 --- a/src/diagrams/mod.rs +++ b/src/diagrams/mod.rs @@ -52,6 +52,10 @@ fn render_diagrams(content: &str, diagrams_dir: &Path) -> Result<String> { let svg = render_graphviz(src)?; svg_to_png(&svg).context("Failed to convert graphviz SVG to PNG") })?; + let content = render_env(&content, "d2", diagrams_dir, |src| { + let svg = render_d2(src)?; + svg_to_png(&svg).context("Failed to convert D2 SVG to PNG") + })?; Ok(content) } @@ -214,6 +218,12 @@ fn render_graphviz(src: &str) -> Result<String> { Ok(svg.finalize()) } +/// Render a D2 diagram to SVG using d2-little (pure Rust port of the d2lang pipeline). +fn render_d2(src: &str) -> Result<String> { + let svg = d2_little::d2_to_svg(src).map_err(|e| anyhow::anyhow!("D2 render error: {}", e))?; + String::from_utf8(svg).context("D2 produced non-UTF8 SVG") +} + /// Parse `[key=val, key2=val2]` into a map. Returns `(map, rest_of_str)`. pub(crate) fn parse_opts(s: &str) -> (HashMap<String, String>, &str) { let s = s.trim_start_matches('\n').trim_start_matches('\r'); @@ -341,6 +351,11 @@ fn configure_font_families(db: &mut resvg::usvg::fontdb::Database) { } /// Configure sans-serif font family. +/// +/// D2 diagrams reference embedded font-family names that never resolve directly, +/// so their text relies entirely on this sans-serif fallback. If none of the +/// preferred fonts exist, fall back to any available family so text never +/// silently disappears on minimal systems. fn configure_sans_serif_family( db: &mut resvg::usvg::fontdb::Database, available: &std::collections::HashSet<String>, @@ -348,6 +363,8 @@ fn configure_sans_serif_family( let sans = ["Arial", "DejaVu Sans", "Liberation Sans", "Noto Sans"]; if let Some(f) = sans.iter().find(|n| available.contains(**n)) { db.set_sans_serif_family(*f); + } else if let Some(any) = available.iter().next() { + db.set_sans_serif_family(any.clone()); } } @@ -513,6 +530,28 @@ mod tests { ); } + #[test] + fn render_d2_produces_svg() { + let svg = render_d2("a -> b -> c").unwrap(); + assert!( + svg.contains("<svg"), + "expected SVG output, got: {}", + &svg[..100.min(svg.len())] + ); + } + + #[test] + fn render_d2_to_png_via_pipeline() { + let dir = tempfile::tempdir().unwrap(); + let content = "\\begin{d2}[caption=Flow]\nx -> y: go\n\\end{d2}"; + let out = render_diagrams(content, dir.path()).unwrap(); + assert!(out.contains("\\includegraphics")); + assert!(out.contains("\\caption{Flow}")); + // exactly one cached PNG written + let pngs = std::fs::read_dir(dir.path()).unwrap().count(); + assert_eq!(pngs, 1); + } + #[test] fn render_env_no_blocks_unchanged() { let content = "hello world"; diff --git a/src/linter/mod.rs b/src/linter/mod.rs index f02a268..e67b0ab 100644 --- a/src/linter/mod.rs +++ b/src/linter/mod.rs @@ -82,6 +82,7 @@ pub fn lint(root: &Path, entry: &str, bib_file: Option<&str>) -> Result<Vec<Lint check_environments(&rel, &content, &mut errors); check_diagram_blocks(&rel, &content, "mermaid", &mut errors); check_diagram_blocks(&rel, &content, "graphviz", &mut errors); + check_diagram_blocks(&rel, &content, "d2", &mut errors); } Ok(errors) @@ -622,4 +623,18 @@ mod tests { let errors = lint(dir.path(), &entry, None).unwrap(); assert!(has_error(&errors, "without matching \\end{graphviz}")); } + + #[test] + fn d2_invalid_pos_is_error() { + let (dir, entry) = setup("\\begin{d2}[pos=Z]\n\\end{d2}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "invalid pos")); + } + + #[test] + fn d2_without_end_is_error() { + let (dir, entry) = setup("\\begin{d2}"); + let errors = lint(dir.path(), &entry, None).unwrap(); + assert!(has_error(&errors, "without matching \\end{d2}")); + } }