Skip to main content

_core/
lib.rs

1//! ikat core: Rust compute for the md→tex/pdf pipeline, exposed to
2//! Python through PyO3. Thin wrappers live in `python/ikat/`; all
3//! parsing, layout, and code generation lives here.
4
5use pyo3::prelude::*;
6
7mod config;
8mod doc;
9mod esc;
10#[cfg(test)]
11mod md_spike;
12mod mermaid;
13mod plot;
14mod table;
15#[cfg(feature = "tectonic")]
16mod tectonic;
17mod texenv;
18
19/// Convert a mermaid flowchart block to a standalone `tikzpicture`.
20///
21/// Raises `ValueError` on empty input, unknown directions, or
22/// unparsable edges.
23#[pyfunction]
24fn flowchart_to_tikz(src: &str) -> PyResult<String> {
25    mermaid::flowchart_to_tikz(src)
26        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))
27}
28
29/// Grouped bar chart with min/max whiskers → `tikzpicture` (pgfplots).
30///
31/// `values/mins/maxs` are per-series lists over groups:
32/// `values[s][i]` is series `s` at group `i`. `legend` is a position
33/// keyword (`auto` default: first collision-free inside corner, else
34/// below; `below`, `top-left`, `top-right`, `bottom-left`,
35/// `bottom-right`, `outside-right`).
36#[pyfunction]
37#[allow(clippy::too_many_arguments)]
38#[pyo3(signature = (title, ylabel, log_y, group_labels, series_names, values, mins, maxs, refline, legend="auto"))]
39fn barchart_to_tikz(
40    title: &str,
41    ylabel: &str,
42    log_y: bool,
43    group_labels: Vec<String>,
44    series_names: Vec<String>,
45    values: Vec<Vec<f64>>,
46    mins: Vec<Vec<f64>>,
47    maxs: Vec<Vec<f64>>,
48    refline: Option<(f64, f64, f64, String)>,
49    legend: &str,
50) -> PyResult<String> {
51    let legend = plot::LegendPos::parse(legend)
52        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))?;
53    plot::barchart(
54        title,
55        ylabel,
56        log_y,
57        &group_labels,
58        &series_names,
59        &values,
60        &mins,
61        &maxs,
62        refline,
63        legend,
64    )
65    .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))
66}
67
68/// Line plot with symmetric error bars → `tikzpicture` (pgfplots).
69/// `legend` is a position keyword (see `barchart_to_tikz`).
70#[pyfunction]
71#[pyo3(signature = (title, xlabel, ylabel, xs, names, yss, errs, legend="auto"))]
72fn lineplot_to_tikz(
73    title: &str,
74    xlabel: &str,
75    ylabel: &str,
76    xs: Vec<f64>,
77    names: Vec<String>,
78    yss: Vec<Vec<f64>>,
79    errs: Vec<Vec<f64>>,
80    legend: &str,
81) -> PyResult<String> {
82    let legend = plot::LegendPos::parse(legend)
83        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))?;
84    if names.len() != yss.len() || names.len() != errs.len() {
85        return Err(pyo3::exceptions::PyValueError::new_err(
86            "lineplot: names/yss/errs length mismatch",
87        ));
88    }
89    let series: Vec<(&str, Vec<f64>, Vec<f64>)> = names
90        .iter()
91        .zip(yss.into_iter())
92        .zip(errs.into_iter())
93        .map(|((n, y), e)| (n.as_str(), y, e))
94        .collect();
95    plot::lineplot(title, xlabel, ylabel, &xs, &series, legend)
96        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))
97}
98
99/// Parse an `ikat.toml` document config. Returns the resolved
100/// per-kind spans plus document settings as a dict.
101#[pyfunction]
102fn parse_config(src: &str) -> PyResult<std::collections::HashMap<String, String>> {
103    let cfg = config::Config::from_toml(src)
104        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))?;
105    let mut m = std::collections::HashMap::new();
106    m.insert("class".to_string(), cfg.document.class.clone());
107    m.insert(
108        "class_options".to_string(),
109        cfg.document.class_options.join(","),
110    );
111    m.insert("columns".to_string(), cfg.document.columns.to_string());
112    m.insert("margins".to_string(), cfg.document.margins.clone());
113    m.insert("template_preamble_file".to_string(), cfg.template.preamble_file.clone());
114    m.insert("template_preamble_append".to_string(), cfg.template.preamble_append.join("\n"));
115    m.insert(
116        "template_abstract_before_maketitle".to_string(),
117        cfg.template.abstract_before_maketitle.to_string(),
118    );
119    m.insert("float_pos_default".to_string(), format!("{:?}", cfg.floats.pos_default).to_lowercase());
120    m.insert("float_topfraction".to_string(), cfg.floats.topfraction.to_string());
121    m.insert("float_bottomfraction".to_string(), cfg.floats.bottomfraction.to_string());
122    m.insert("float_textfraction".to_string(), cfg.floats.textfraction.to_string());
123    m.insert("float_floatpagefraction".to_string(), cfg.floats.floatpagefraction.to_string());
124    m.insert("float_topnumber".to_string(), cfg.floats.topnumber.to_string());
125    m.insert("float_bottomnumber".to_string(), cfg.floats.bottomnumber.to_string());
126    m.insert("float_barrier_sections".to_string(), cfg.floats.barrier_sections.to_string());
127    m.insert("template_skeleton".to_string(), cfg.template.skeleton.clone());
128    for kind in ["diagram", "plot", "table", "picture", "default"] {
129        let span = cfg.spans.for_kind(kind);
130        m.insert(format!("span_{kind}"), span.latex_env().to_string());
131        m.insert(format!("width_{kind}"), span.latex_width().to_string());
132    }
133    Ok(m)
134}
135
136/// Full document build: Markdown + ikat.toml + spec JSON → result JSON.
137///
138/// `spec_json` carries diagrams/plots/table captions, thanks, author,
139/// bibliography name, graphicspaths, and the validated `.bib` keyset.
140/// Returns `{"title","body","tex","n_diagrams","n_tables"}`.
141#[pyfunction]
142fn build_document(md_text: &str, toml_src: &str, spec_json: &str) -> PyResult<String> {
143    let spec: doc::BuildSpec = serde_json::from_str(spec_json)
144        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("spec: {e}")))?;
145    let r = doc::build_document(md_text, toml_src, &spec)
146        .map_err(pyo3::exceptions::PyValueError::new_err)?;
147    serde_json::to_string(&r)
148        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
149}
150
151/// BibTeX-safe derivative of a `.bib` source (brace bare authors,
152/// escape specials outside author/url/doi fields).
153#[pyfunction]
154fn bib_safe(bib_src: &str) -> String {
155    table::bib_safe(bib_src)
156}
157
158/// Every `@type{key,` entry key in a `.bib` source.
159#[pyfunction]
160fn bib_keys(bib_src: &str) -> Vec<String> {
161    let mut v: Vec<String> = table::bib_keys_of(bib_src).into_iter().collect();
162    v.sort();
163    v
164}
165
166/// LaTeX packages a woven body needs: TikZ for inline diagrams,
167/// pgfplots for data plots. Precompiled PDFs need neither.
168#[pyfunction]
169fn tex_requirements(has_tikz: bool, has_plots: bool) -> Vec<String> {
170    doc::tex_requirements(has_tikz, has_plots)
171}
172
173/// Scan any `.tex` preamble for `\usepackage` names (comments
174/// stripped, TikZ/pgfplots use inferred). Pure scan; probing the
175/// disk stays in `texenv.py`.
176#[pyfunction]
177fn used_packages(tex: &str) -> Vec<String> {
178    texenv::used_packages(tex)
179}
180
181/// The `\documentclass` name in a `.tex` source, if any.
182#[pyfunction]
183fn document_class(tex: &str) -> Option<String> {
184    texenv::document_class(tex)
185}
186
187/// Map LaTeX names + optional class to `(probe file, tlmgr package)`
188/// pairs, deduplicated in first-use order.
189#[pyfunction]
190fn package_needs(names: Vec<String>, class: Option<String>) -> Vec<(String, String)> {
191    texenv::package_needs(&names, class.as_deref())
192}
193
194/// Heuristic package scan for arbitrary `.tex`: `[H]` and
195/// `\FloatBarrier` map to their packages.
196#[pyfunction]
197fn tex_extra_packages(tex: &str) -> Vec<String> {
198    doc::tex_extra_packages(tex)
199}
200
201/// Tectonic engine (feature `tectonic` only): full `.tex` in,
202/// PDF bytes out. Absent without the feature — see `compile.py`.
203#[cfg(feature = "tectonic")]
204#[pyfunction]
205fn compile_tectonic_pdf(tex: &str) -> PyResult<Vec<u8>> {
206    crate::tectonic::compile_tectonic(tex).map_err(pyo3::exceptions::PyRuntimeError::new_err)
207}
208
209/// ikat core (Rust): mermaid→TikZ, data→pgfplots, document config.
210#[pymodule]
211fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
212    m.add_function(wrap_pyfunction!(flowchart_to_tikz, m)?)?;
213    m.add_function(wrap_pyfunction!(barchart_to_tikz, m)?)?;
214    m.add_function(wrap_pyfunction!(lineplot_to_tikz, m)?)?;
215    m.add_function(wrap_pyfunction!(parse_config, m)?)?;
216    m.add_function(wrap_pyfunction!(build_document, m)?)?;
217    m.add_function(wrap_pyfunction!(bib_safe, m)?)?;
218    m.add_function(wrap_pyfunction!(bib_keys, m)?)?;
219    m.add_function(wrap_pyfunction!(tex_requirements, m)?)?;
220    m.add_function(wrap_pyfunction!(tex_extra_packages, m)?)?;
221    m.add_function(wrap_pyfunction!(used_packages, m)?)?;
222    m.add_function(wrap_pyfunction!(document_class, m)?)?;
223    m.add_function(wrap_pyfunction!(package_needs, m)?)?;
224    #[cfg(feature = "tectonic")]
225    m.add_function(wrap_pyfunction!(compile_tectonic_pdf, m)?)?;
226    Ok(())
227}