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 autolayout;
8mod config;
9mod doc;
10mod error;
11mod md;
12mod emit;
13mod esc;
14mod mathwidth;
15mod md_spike;
16mod mermaid;
17mod plot;
18mod layout;
19mod preset;
20mod sequence;
21mod state;
22mod table;
23#[cfg(all(feature = "tectonic", target_os = "linux"))]
24mod tectonic;
25mod texenv;
26
27/// Rendered error helper: the Display string crosses the
28/// boundary (exception type unchanged — still `ValueError`).
29fn verr(e: crate::error::Error) -> pyo3::PyErr {
30    pyo3::exceptions::PyValueError::new_err(e.to_string())
31}
32
33/// Convert a mermaid flowchart block to a standalone `tikzpicture`.
34///
35/// Raises `ValueError` on empty input, unknown directions, or
36/// unparsable edges. Errors carry snippet-relative line numbers
37/// (base 1 — bare snippets have no file).
38#[pyfunction]
39fn flowchart_to_tikz(src: &str) -> PyResult<String> {
40    mermaid::flowchart_to_tikz(src, 1).map_err(verr)
41}
42
43/// Grouped bar chart with min/max whiskers → `tikzpicture` (pgfplots).
44///
45/// `values/mins/maxs` are per-series lists over groups:
46/// `values[s][i]` is series `s` at group `i`. `legend` is a position
47/// keyword (`auto` default: first collision-free inside corner, else
48/// below; `below`, `top-left`, `top-right`, `bottom-left`,
49/// `bottom-right`, `outside-right`).
50#[pyfunction]
51#[allow(clippy::too_many_arguments)]
52#[pyo3(signature = (title, ylabel, log_y, group_labels, series_names, values, mins, maxs, refline, legend="auto"))]
53fn barchart_to_tikz(
54    title: &str,
55    ylabel: &str,
56    log_y: bool,
57    group_labels: Vec<String>,
58    series_names: Vec<String>,
59    values: Vec<Vec<f64>>,
60    mins: Vec<Vec<f64>>,
61    maxs: Vec<Vec<f64>>,
62    refline: Option<(f64, f64, f64, String)>,
63    legend: &str,
64) -> PyResult<String> {
65    let legend = plot::LegendPos::parse(legend).map_err(verr)?;
66    plot::barchart(
67        title,
68        ylabel,
69        log_y,
70        &group_labels,
71        &series_names,
72        &values,
73        &mins,
74        &maxs,
75        refline,
76        legend,
77    )
78    .map_err(verr)
79}
80
81/// Line plot with symmetric error bars → `tikzpicture` (pgfplots).
82/// `legend` is a position keyword (see `barchart_to_tikz`).
83#[pyfunction]
84#[pyo3(signature = (title, xlabel, ylabel, xs, names, yss, errs, legend="auto"))]
85fn lineplot_to_tikz(
86    title: &str,
87    xlabel: &str,
88    ylabel: &str,
89    xs: Vec<f64>,
90    names: Vec<String>,
91    yss: Vec<Vec<f64>>,
92    errs: Vec<Vec<f64>>,
93    legend: &str,
94) -> PyResult<String> {
95    let legend = plot::LegendPos::parse(legend).map_err(verr)?;
96    if names.len() != yss.len() || names.len() != errs.len() {
97        return Err(verr(crate::error::Error::data(
98            "lineplot: names/yss/errs length mismatch",
99            format!("names={} yss={} errs={}", names.len(), yss.len(), errs.len()),
100        )));
101    }
102    let series: Vec<(&str, Vec<f64>, Vec<f64>)> = names
103        .iter()
104        .zip(yss.into_iter())
105        .zip(errs.into_iter())
106        .map(|((n, y), e)| (n.as_str(), y, e))
107        .collect();
108    plot::lineplot(title, xlabel, ylabel, &xs, &series, legend).map_err(verr)
109}
110
111/// Benchmark-JSON preset → `tikzpicture` (see `plot` for emitters).
112/// `legend`/`footnote` ride inside the JSON; errors name keys.
113#[pyfunction]
114fn preset_to_tikz(src: &str) -> PyResult<String> {
115    preset::preset_to_tikz(src).map_err(verr)
116}
117
118/// Parse an `ikat.toml` document config. Returns the resolved
119/// per-kind spans plus document settings as a dict.
120#[pyfunction]
121fn parse_config(src: &str) -> PyResult<std::collections::HashMap<String, String>> {
122    let cfg = config::Config::from_toml(src)
123        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e))?;
124    let mut m = std::collections::HashMap::new();
125    m.insert("class".to_string(), cfg.document.class.clone());
126    m.insert(
127        "class_options".to_string(),
128        cfg.document.class_options.join(","),
129    );
130    m.insert("columns".to_string(), cfg.document.columns.to_string());
131    m.insert("margins".to_string(), cfg.document.margins.clone());
132    m.insert("template_preamble_file".to_string(), cfg.template.preamble_file.clone());
133    m.insert("template_preamble_append".to_string(), cfg.template.preamble_append.join("\n"));
134    m.insert(
135        "template_abstract_before_maketitle".to_string(),
136        cfg.template.abstract_before_maketitle.to_string(),
137    );
138    m.insert("float_pos_default".to_string(), format!("{:?}", cfg.floats.pos_default).to_lowercase());
139    m.insert("float_topfraction".to_string(), cfg.floats.topfraction.to_string());
140    m.insert("float_bottomfraction".to_string(), cfg.floats.bottomfraction.to_string());
141    m.insert("float_textfraction".to_string(), cfg.floats.textfraction.to_string());
142    m.insert("float_floatpagefraction".to_string(), cfg.floats.floatpagefraction.to_string());
143    m.insert("float_topnumber".to_string(), cfg.floats.topnumber.to_string());
144    m.insert("float_bottomnumber".to_string(), cfg.floats.bottomnumber.to_string());
145    m.insert("float_barrier_sections".to_string(), cfg.floats.barrier_sections.to_string());
146    m.insert("typography_keep_with_next".to_string(), cfg.typography.keep_with_next.to_string());
147    m.insert("typography_min_lines".to_string(), cfg.typography.min_lines.to_string());
148    m.insert("template_skeleton".to_string(), cfg.template.skeleton.clone());
149    for kind in ["diagram", "plot", "table", "picture", "default"] {
150        let span = cfg.spans.for_kind(kind);
151        m.insert(format!("span_{kind}"), span.latex_env().to_string());
152        m.insert(format!("width_{kind}"), span.latex_width().to_string());
153    }
154    Ok(m)
155}
156
157/// Full document build: Markdown + ikat.toml + spec JSON → result JSON.
158///
159/// `spec_json` carries diagrams/plots/table captions, thanks, author,
160/// bibliography name, graphicspaths, and the validated `.bib` keyset.
161/// Returns `{"title","body","tex","n_diagrams","n_tables"}`.
162#[pyfunction]
163fn build_document(md_text: &str, toml_src: &str, spec_json: &str) -> PyResult<String> {
164    let spec: doc::BuildSpec = serde_json::from_str(spec_json)
165        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("spec: {e}")))?;
166    let r = doc::build_document(md_text, toml_src, &spec).map_err(verr)?;
167    serde_json::to_string(&r)
168        .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))
169}
170
171/// Auto-layout (M7.1): measure every floatable fence in `md_text`
172/// and suggest `span`/`pos`/`width` per fence (`spec_json` is the
173/// same `BuildSpec` JSON `build_document` takes; `toml_src` may
174/// carry `[layout]` knobs). Returns a JSON list of suggestions —
175/// advisory only, nothing is rewritten. Raises `ValueError` on
176/// strict-subset or registry errors, with file lines.
177#[pyfunction]
178fn suggest_layout(md_text: &str, toml_src: &str, spec_json: &str) -> PyResult<String> {
179    let spec: doc::BuildSpec = serde_json::from_str(spec_json)
180        .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("spec: {e}")))?;
181    autolayout::suggest_layout_json(md_text, toml_src, &spec).map_err(verr)
182}
183
184/// BibTeX-safe derivative of a `.bib` source (brace bare authors,
185/// escape specials outside author/url/doi fields).
186#[pyfunction]
187fn bib_safe(bib_src: &str) -> String {
188    table::bib_safe(bib_src)
189}
190
191/// Every `@type{key,` entry key in a `.bib` source.
192#[pyfunction]
193fn bib_keys(bib_src: &str) -> Vec<String> {
194    let mut v: Vec<String> = table::bib_keys_of(bib_src).into_iter().collect();
195    v.sort();
196    v
197}
198
199/// Does this `.tex` fragment contain a `tikzpicture`? (standalone builder)
200#[pyfunction]
201fn tikz_needed(tex: &str) -> bool {
202    doc::tikz_needed(tex)
203}
204
205/// Does this `.tex` fragment contain a pgfplots `axis`?
206#[pyfunction]
207fn plots_needed(tex: &str) -> bool {
208    doc::plots_needed(tex)
209}
210
211/// LaTeX packages a woven body needs: TikZ for inline diagrams,
212/// pgfplots for data plots. Precompiled PDFs need neither.
213#[pyfunction]
214fn tex_requirements(has_tikz: bool, has_plots: bool) -> Vec<String> {
215    doc::tex_requirements(has_tikz, has_plots)
216}
217
218/// Scan any `.tex` preamble for `\usepackage` names (comments
219/// stripped, TikZ/pgfplots use inferred). Pure scan; probing the
220/// disk stays in `texenv.py`.
221#[pyfunction]
222fn used_packages(tex: &str) -> Vec<String> {
223    texenv::used_packages(tex)
224}
225
226/// The `\documentclass` name in a `.tex` source, if any.
227#[pyfunction]
228fn document_class(tex: &str) -> Option<String> {
229    texenv::document_class(tex)
230}
231
232/// Map LaTeX names + optional class to `(probe file, tlmgr package)`
233/// pairs, deduplicated in first-use order.
234#[pyfunction]
235fn package_needs(names: Vec<String>, class: Option<String>) -> Vec<(String, String)> {
236    texenv::package_needs(&names, class.as_deref())
237}
238
239/// Heuristic package scan for arbitrary `.tex`: `[H]` and
240/// `\FloatBarrier` map to their packages.
241#[pyfunction]
242fn tex_extra_packages(tex: &str) -> Vec<String> {
243    doc::tex_extra_packages(tex)
244}
245
246/// Tectonic engine (feature `tectonic`, Linux builds only): full
247/// `.tex` in, PDF bytes out. Absent elsewhere — the Python cascade
248/// in `compile.py` falls back to the `tectonic` binary.
249#[cfg(all(feature = "tectonic", target_os = "linux"))]
250#[pyfunction]
251fn compile_tectonic_pdf(tex: &str) -> PyResult<Vec<u8>> {
252    crate::tectonic::compile_tectonic(tex).map_err(pyo3::exceptions::PyRuntimeError::new_err)
253}
254
255/// ikat core (Rust): mermaid→TikZ, data→pgfplots, document config.
256#[pymodule]
257fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
258    m.add_function(wrap_pyfunction!(flowchart_to_tikz, m)?)?;
259    m.add_function(wrap_pyfunction!(barchart_to_tikz, m)?)?;
260    m.add_function(wrap_pyfunction!(lineplot_to_tikz, m)?)?;
261    m.add_function(wrap_pyfunction!(preset_to_tikz, m)?)?;
262    m.add_function(wrap_pyfunction!(parse_config, m)?)?;
263    m.add_function(wrap_pyfunction!(build_document, m)?)?;
264    m.add_function(wrap_pyfunction!(bib_safe, m)?)?;
265    m.add_function(wrap_pyfunction!(bib_keys, m)?)?;
266    m.add_function(wrap_pyfunction!(tex_requirements, m)?)?;
267    m.add_function(wrap_pyfunction!(tikz_needed, m)?)?;
268    m.add_function(wrap_pyfunction!(plots_needed, m)?)?;
269    m.add_function(wrap_pyfunction!(tex_extra_packages, m)?)?;
270    m.add_function(wrap_pyfunction!(used_packages, m)?)?;
271    m.add_function(wrap_pyfunction!(document_class, m)?)?;
272    m.add_function(wrap_pyfunction!(package_needs, m)?)?;
273    m.add_function(wrap_pyfunction!(suggest_layout, m)?)?;
274    #[cfg(all(feature = "tectonic", target_os = "linux"))]
275    m.add_function(wrap_pyfunction!(compile_tectonic_pdf, m)?)?;
276    Ok(())
277}