Skip to main content

svg2tex_rs/
lib.rs

1//! Library entry points for converting SVG input into PDF operators or TeX.
2//!
3//! The crate keeps CLI parsing separate from conversion so tests and other
4//! callers can run the same pipeline through [`render_output`].
5
6mod cli;
7mod converter;
8mod preprocess;
9mod tex_engine;
10mod tex_format;
11mod validation;
12
13pub use cli::Args;
14pub use tex_engine::TexEngine;
15pub use tex_format::TexFormat;
16
17use converter::PdfConverter;
18use preprocess::preprocess_svg;
19use std::fs::File;
20use std::io::Write;
21use usvg::{Options, Tree};
22use validation::{analyze_tree, TextFontRequirement};
23
24/// Renders an SVG input into either raw PDF operators or TeX source.
25///
26/// This is the main library entry point used by both the CLI and tests.
27pub fn render_output(args: &Args) -> Result<String, String> {
28    if !args.fallback_dpi.is_finite() || args.fallback_dpi <= 0.0 {
29        return Err(format!(
30            "Invalid --fallback-dpi value '{}': expected a positive number.",
31            args.fallback_dpi
32        ));
33    }
34
35    if let Some(font_size) = args.font_size {
36        if !font_size.is_finite() || font_size <= 0.0 {
37            return Err(format!(
38                "Invalid --font-size value '{}': expected a positive number.",
39                font_size
40            ));
41        }
42    }
43
44    let mut opt = Options::default();
45    if let Some(font_family) = &args.font_family {
46        opt.font_family = font_family.clone();
47    }
48    if let Some(font_size) = args.font_size {
49        opt.font_size = font_size;
50    }
51    if let Some(family) = &args.serif_family {
52        opt.fontdb_mut().set_serif_family(family.clone());
53    }
54    if let Some(family) = &args.sans_serif_family {
55        opt.fontdb_mut().set_sans_serif_family(family.clone());
56    }
57    if let Some(family) = &args.cursive_family {
58        opt.fontdb_mut().set_cursive_family(family.clone());
59    }
60    if let Some(family) = &args.fantasy_family {
61        opt.fontdb_mut().set_fantasy_family(family.clone());
62    }
63    if let Some(family) = &args.monospace_family {
64        opt.fontdb_mut().set_monospace_family(family.clone());
65    }
66    if !args.no_system_fonts {
67        opt.fontdb_mut().load_system_fonts();
68    }
69
70    for font_file in &args.font_files {
71        opt.fontdb_mut()
72            .load_font_file(font_file)
73            .map_err(|e| format!("Error loading font file '{}': {}", font_file.display(), e))?;
74    }
75
76    for font_dir in &args.font_dirs {
77        opt.fontdb_mut().load_fonts_dir(font_dir);
78    }
79
80    let svg_data = std::fs::read(&args.input)
81        .map_err(|e| format!("Error reading input file '{}': {}", args.input, e))?;
82    let svg_data = preprocess_svg(&svg_data);
83
84    let tree = Tree::from_data(&svg_data, &opt).map_err(|e| format!("Error parsing SVG: {}", e))?;
85    let analysis = analyze_tree(&tree, args.embed_images);
86
87    if analysis.has_text_nodes {
88        if args.report_fonts {
89            report_text_fonts(&analysis.text_font_requirements);
90        }
91
92        if !args.no_system_fonts {
93            let requested = analysis
94                .text_font_requirements
95                .iter()
96                .map(TextFontRequirement::summary)
97                .collect::<Vec<_>>();
98            eprintln!(
99                "Warning: Text conversion currently depends on system fonts. Requested text fonts: {}. \
100Use --no-system-fonts together with --font-file/--font-dir and explicit default font families for reproducible output.",
101                if requested.is_empty() {
102                    "(none)".to_string()
103                } else {
104                    requested.join("; ")
105                }
106            );
107        }
108
109        let missing_named_families = analysis.missing_named_font_families(tree.fontdb());
110        if args.strict_fonts {
111            if !args.no_system_fonts {
112                return Err(
113                    "--strict-fonts requires --no-system-fonts so text rendering does not depend on host font discovery."
114                        .to_string(),
115                );
116            }
117
118            if !missing_named_families.is_empty() {
119                return Err(format!(
120                    "Missing named fonts required by SVG text: {}. Load them with --font-file/--font-dir or change the default font families.",
121                    missing_named_families.join(", ")
122                ));
123            }
124        } else if !missing_named_families.is_empty() {
125            eprintln!(
126                "Warning: Some named fonts requested by SVG text were not found in the loaded font database: {}. Fallback shaping may change layout.",
127                missing_named_families.join(", ")
128            );
129        }
130    }
131
132    let size = tree.size();
133    let mut converter = PdfConverter::new(
134        size,
135        args.embed_images,
136        args.fallback_dpi,
137        args.engine,
138        args.tex_format,
139    );
140    // Strict mode rejects unsupported features before conversion so the caller
141    // can decide whether a raster fallback is acceptable.
142    if !analysis.unsupported_features.is_empty() && args.strict {
143        return Err(format!(
144            "Unsupported SVG features detected: {}. Re-run without --strict to rasterize only the unsupported subtrees.",
145            analysis.unsupported_features.join(", ")
146        ));
147    }
148
149    if !analysis.unsupported_features.is_empty() {
150        eprintln!(
151            "Info: Hybrid rendering enabled for unsupported SVG features: {}",
152            analysis.unsupported_features.join(", ")
153        );
154    }
155
156    match converter.convert(&tree) {
157        Ok(()) => {}
158        Err(err) if !analysis.unsupported_features.is_empty() => {
159            eprintln!(
160                "Info: Hybrid rendering failed ({}); falling back to full-document rasterization.",
161                err
162            );
163            converter.rasterize_tree(&tree)?;
164        }
165        Err(err) => return Err(err),
166    }
167
168    Ok(if args.tex {
169        converter.generate_latex()
170    } else {
171        converter.generate_pdf_literal()
172    })
173}
174
175/// Runs the full conversion pipeline and writes the result to stdout or a file.
176pub fn run(args: Args) -> Result<(), String> {
177    let output_content = render_output(&args)?;
178
179    if let Some(output_path) = args.output {
180        let mut file = File::create(&output_path)
181            .map_err(|e| format!("Error creating output file '{}': {}", output_path, e))?;
182
183        file.write_all(output_content.as_bytes())
184            .map_err(|e| format!("Error writing to output file: {}", e))?;
185
186        eprintln!("Output written to: {}", output_path);
187    } else {
188        print!("{}", output_content);
189    }
190
191    Ok(())
192}
193
194fn report_text_fonts(requirements: &[TextFontRequirement]) {
195    if requirements.is_empty() {
196        eprintln!("Info: No text font requirements were detected.");
197        return;
198    }
199
200    eprintln!("Text font requirements:");
201    for requirement in requirements {
202        eprintln!("  - {}", requirement.summary());
203    }
204}