flams_stex/
lib.rs

1//#![feature(lazy_type_alias)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3
4mod dependencies;
5mod latex;
6pub mod math;
7pub mod quickparse;
8mod rustex;
9
10use crate::dependencies::STeXDependency;
11use either::Either;
12use eyre::Context;
13use flams_ftml::FTML_CONTENT;
14use flams_math_archives::{
15    artifacts::{FileArtifact, FileOrString, FtmlString},
16    backend::{AnyBackend, GlobalBackend, LocalBackend},
17    build_target,
18    formats::{BuildResult, BuildSpec},
19    manager::ArchiveOrGroup,
20    source_format, Archive, LocalArchive, MathArchive,
21};
22use flams_system::FlamsExtension;
23use flams_utils::vecmap::VecSet;
24use ftml_uris::{ArchiveId, DocumentUri, UriWithArchive, UriWithPath};
25pub use rustex::{OutputCont, RusTeX};
26use std::path::{Path, PathBuf};
27
28flams_system::register_exension!(FlamsExtension {
29    name: "stex",
30    on_start: || {
31        RusTeX::initialize();
32        math::RusTeXMath::initialize();
33    },
34    on_build_result: |_, _, _, _| ()
35});
36
37source_format!(STEX {
38    name: "stex",
39    description: "(Semantically annotated) LaTeX",
40    targets: &[
41        PDFLATEX_FIRST.id(),
42        PDFLATEX.id(),
43        RUSTEX.id(),
44        FTML_CONTENT.id()
45    ],
46    file_extensions: &["tex", "ltx"],
47    dependencies: dependencies::get_deps
48});
49
50build_target!(PDFLATEX_FIRST {
51    name: "pdflatex_first",
52    description: "Run pdflatex and bibtex/biber/index once",
53    // yields AUX
54    run: pdflatex_first
55});
56
57build_target!(PDFLATEX {
58    name: "pdflatex",
59    description: "Run pdflatex a second time",
60    // yields PDF
61    // requires AUX
62    run: pdflatex_second
63});
64
65build_target!(RUSTEX {
66    name: "rustex",
67    description: "Run RusTeX tex->html only",
68    // yields: FTML,
69    // requires AUX
70    run: rustex
71});
72
73// build_result!(aux @ "LaTeX aux/bbl/toc files, as generated by pdflatex+bibtex/biber/mkindex");
74
75pub struct PDF(PathBuf);
76impl FileArtifact for PDF {
77    fn kind(&self) -> &'static str {
78        "pdf"
79    }
80    fn as_any(&self) -> &dyn std::any::Any {
81        self as _
82    }
83    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
84        self as _
85    }
86    fn source(&self) -> &Path {
87        &self.0
88    }
89}
90
91#[allow(clippy::needless_pass_by_value)]
92fn pdflatex_first(task: BuildSpec) -> BuildResult {
93    let Either::Left(path) = task.source else {
94        return BuildResult {
95            log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
96            result: Err(Vec::new()),
97        };
98    };
99    latex::clean(path);
100    let log = path.with_extension("log");
101    let mh = task
102        .backend
103        .mathhubs()
104        .map(|p| p.display().to_string())
105        .collect::<Vec<_>>()
106        .join(",");
107    let ret = latex::pdflatex_and_bib(path, [("STEX_WRITESMS", "true"), ("MATHHUB", &mh)]);
108    if ret.is_ok() {
109        BuildResult {
110            log: FileOrString::File(log),
111            result: Ok(Some(Box::new(PDF(path.with_extension("pdf"))) as _)),
112        }
113    } else {
114        BuildResult {
115            log: FileOrString::File(log),
116            result: Err(Vec::new()),
117        }
118    }
119}
120
121#[allow(clippy::needless_pass_by_value)]
122fn pdflatex_second(task: BuildSpec) -> BuildResult {
123    let Either::Left(path) = task.source else {
124        return BuildResult {
125            log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
126            result: Err(Vec::new()),
127        };
128    };
129    let log = path.with_extension("log");
130    let mh = task
131        .backend
132        .mathhubs()
133        .map(|p| p.display().to_string())
134        .collect::<Vec<_>>()
135        .join(",");
136    let ret = latex::pdflatex(path, [("STEX_USESMS", "true"), ("MATHHUB", &mh)]);
137    if ret.is_ok() {
138        BuildResult {
139            log: FileOrString::File(log),
140            result: Ok(Some(Box::new(PDF(path.with_extension("pdf"))) as _)),
141        }
142    } else {
143        BuildResult {
144            log: FileOrString::File(log),
145            result: Err(Vec::new()),
146        }
147    }
148}
149
150#[allow(clippy::needless_pass_by_value)]
151fn rustex(task: BuildSpec) -> BuildResult {
152    // TODO make work with string as well
153    let Either::Left(path) = task.source else {
154        return BuildResult {
155            log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
156            result: Err(Vec::new()),
157        };
158    };
159    let out = path.with_extension("rlog");
160    let ocl = out.clone();
161    let mh = task
162        .backend
163        .mathhubs()
164        .map(|p| p.display().to_string())
165        .collect::<Vec<_>>()
166        .join(",");
167    let run = move || {
168        RusTeX::get()
169            .map_err(|()| "Could not initialize RusTeX".to_string())
170            .and_then(|e| {
171                std::panic::catch_unwind(move || {
172                    e.run_with_envs(
173                        path,
174                        false,
175                        [
176                            ("STEX_USESMS".to_string(), "true".to_string()),
177                            ("MATHHUB".to_string(), mh),
178                        ],
179                        Some(&ocl),
180                    )
181                })
182                .map_err(|e| {
183                    #[allow(clippy::option_if_let_else)]
184                    if let Some(s) = e.downcast_ref::<&str>() {
185                        (*s).to_string()
186                    } else if let Ok(s) = e.downcast::<String>() {
187                        *s
188                    } else {
189                        "Unknown RusTeX error".to_string()
190                    }
191                })
192            })
193    };
194    #[cfg(debug_assertions)]
195    let ret = {
196        std::thread::scope(move |s| {
197            std::thread::Builder::new()
198                .stack_size(16 * 1024 * 1024)
199                .spawn_scoped(s, run)
200                .expect("foo")
201                .join()
202                .expect("foo")
203        })
204    };
205    #[cfg(not(debug_assertions))]
206    let ret = { run() };
207    match ret {
208        Err(s) => BuildResult {
209            log: FileOrString::Str(s.into_boxed_str()),
210            result: Err(Vec::new()),
211        },
212        Ok(Err(_)) => BuildResult {
213            log: FileOrString::File(out),
214            result: Err(Vec::new()),
215        },
216        Ok(Ok(s)) => {
217            latex::clean(path);
218            BuildResult {
219                log: FileOrString::File(out),
220                result: Ok(Some(Box::new(FtmlString(s.into_boxed_str())) as _)),
221            }
222        }
223    }
224}
225
226static OPTIONS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(||
227    // SAFETY: known to be well-formed
228    unsafe{ regex::Regex::new(
229        r"\\(?<cmd>documentclass|usepackage|RequirePackage)(?<opts>\[[^\]]*\])?\{(?<name>notesslides|stex|hwexam|problem)\}"
230    ).unwrap_unchecked() });
231
232#[allow(clippy::trivial_regex)]
233static LIBS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(||
234    // SAFETY: known to be well-formed
235    unsafe{ regex::Regex::new(r"\\libinput\{").unwrap_unchecked() });
236
237macro_rules! err {
238    ($fmt:expr) => {return Err(eyre::eyre!($fmt))};
239    ($fmt:expr, $($args:tt)*) => {return Err(eyre::eyre!($fmt,$($args)*))};
240    ($e:expr => $fmt:expr) => { $e.wrap_err($fmt)?};
241    ($e:expr => $fmt:expr, $($args:tt)*) => { $e.wrap_err_with(|| format!($fmt,$($args)*))?};
242}
243
244/// # Errors
245#[allow(clippy::too_many_lines)]
246pub fn export_standalone(doc: &DocumentUri, file: &Path, target_dir: &Path) -> eyre::Result<()> {
247    use std::fmt::Write;
248    if !file.extension().is_some_and(|e| e == "tex") {
249        err!("Not a .tex file: {}", file.display());
250    }
251
252    // safe, because we earlier checked that it has extension .tex => it has a file name
253    let file_name = unsafe { file.file_name().unwrap_unchecked() };
254
255    let mh_path = target_dir.join("mathhub");
256    err!(
257        std::fs::create_dir_all(&mh_path) =>
258        "Invalid target directory: {}",
259        mh_path.display()
260    );
261    let archive = doc.archive_id();
262
263    let mh = flams_math_archives::mathhub::mathhubs()
264        .iter()
265        .map(|p| p.display().to_string())
266        .collect::<Vec<_>>()
267        .join(",");
268    let Ok(()) = latex::pdflatex_and_bib(file, [("STEX_WRITESMS", "true"), ("MATHHUB", &mh)])
269    else {
270        err!(
271            "failed to build {}\nCheck .log file for details",
272            file.display()
273        );
274    };
275
276    let sms = file.with_extension("sms");
277    let sms_target = target_dir.join(file_name).with_extension("sms");
278    err!(std::fs::copy(&sms, &sms_target) => "Failed to copy file {}",sms.display() );
279
280    let orig_txt = err!(
281        std::fs::read_to_string(file) =>
282        "failed to open file {}",
283        file.display()
284    );
285    let Some(begin) = orig_txt.find("\\begin{document}") else {
286        err!("No \\begin{{document}} found!")
287    };
288    let mut txt = orig_txt[..begin].to_string();
289    //orig_txt.truncate(begin);
290    let rel_path = doc.path().map_or_else(
291        || file_name.display().to_string(),
292        |p| format!("{p}/{}", file_name.display()),
293    );
294    err!(
295        write!(
296            txt,
297            "\n\\begin{{document}}\n  \\inputref[{archive}]{{{rel_path}}}\n\\end{{document}}"
298        ) =>
299        "impossible",
300    );
301
302    let mut matched = false;
303    let txt = OPTIONS.replace(&txt, |cap: &regex::Captures<'_>| {
304        matched = true;
305        // This is safe, because the named groups are necessary components of the regex, so a match
306        // entails they are defined.
307        let (cmd, name) = unsafe {
308            (
309                cap.name("cmd").unwrap_unchecked().as_str(),
310                cap.name("name").unwrap_unchecked().as_str(),
311            )
312        };
313        cap.name("opts").map_or_else(
314            || format!("\\{cmd}[mathhub=./mathhub,usesms]{{{name}}}"),
315            |opts| {
316                format!(
317                    "\\{cmd}[{},mathhub=./mathhub,usesms]{{{name}}}",
318                    &opts.as_str()[1..opts.as_str().len() - 1]
319                )
320            },
321        )
322    });
323    if !matched {
324        err!(
325            "No sTeX \\documentclass or \\package found in {}",
326            file.display()
327        );
328    }
329    let rep = format!("\\libinput[{archive}]{{");
330    let txt = LIBS.replace_all(&txt, &rep);
331
332    let tex_target = target_dir.join(file_name);
333    err!(std::fs::write(&tex_target, txt.as_bytes()) => "Failed to write to file {}",tex_target.display());
334
335    copy("stex.sty", &target_dir)?;
336    copy("stex-logo.sty", &target_dir)?;
337    copy("stex-backend-pdflatex.cfg", &target_dir)?;
338    copy("stex-highlighting.sty", &target_dir)?;
339    copy("stexthm.sty", &target_dir)?;
340    // stex-compat?
341
342    let mut todos = vec![(orig_txt, file.to_owned(), doc.clone())];
343    let mut archives = VecSet(Vec::with_capacity(4));
344    while let Some((txt, f, d)) = todos.pop() {
345        if !archives.0.contains(d.archive_id()) {
346            archives.0.push(d.archive_id().clone());
347            do_archive(d.archive_id(), &mh_path)?;
348        }
349        // by construction, the files in todos have a file name
350        let name = unsafe { f.file_name().unwrap_unchecked() };
351        let target_file = d.path().map_or_else(
352            || mh_path.join(d.archive_id().to_string()).join("source"),
353            |p| {
354                mh_path
355                    .join(d.archive_id().to_string())
356                    .join("source")
357                    .join(p.to_string())
358            },
359        );
360        err!(std::fs::create_dir_all(&target_file) => "Failed to create directory {}",target_file.display());
361        let target_file = target_file.join(name);
362        err!(std::fs::copy(&f, target_file) => "Failed to copy file {}",f.display());
363        for dep in dependencies::parse_deps(&txt, &f, &d, &AnyBackend::Global) {
364            match dep {
365                STeXDependency::Inputref { archive, filepath } => {
366                    let archive = archive.as_ref().unwrap_or(&d.path.archive.id);
367                    let Some((d, f)) = GlobalBackend.with_local_archive(archive, |a| {
368                        a.and_then(|a| {
369                            let f = a.source_dir().join(&*filepath);
370                            let d = DocumentUri::from_archive_relpath(a.uri().clone(), &*filepath)
371                                .ok()?;
372                            Some((d, f))
373                        })
374                    }) else {
375                        err!("Could not find document for file {}", f.display())
376                    };
377                    let txt = err!(
378                        std::fs::read_to_string(&f) =>
379                        "failed to open file {}",
380                        f.display()
381                    );
382                    todos.push((txt, f, d));
383                }
384                STeXDependency::Img { archive, filepath } => {
385                    let archive = archive.as_ref().unwrap_or(&d.path.archive.id);
386                    let Some(source) = GlobalBackend.with_local_archive(archive, |a| {
387                        a.map(|a| a.path().join("source").join(&*filepath))
388                    }) else {
389                        err!("Could not find image file {}", f.display())
390                    };
391                    let img_target = mh_path
392                        .join(archive.to_string())
393                        .join("source")
394                        .join(&*filepath);
395                    if !source.exists() {
396                        err!("img file not found: {}", source.display())
397                    }
398                    // safe, because file exists and is not root
399                    let parent = unsafe { img_target.parent().unwrap_unchecked() };
400                    err!(std::fs::create_dir_all(&parent) => "Error creating directory {}",parent.display());
401                    err!(std::fs::copy(&source,&img_target) => "Error copying {}",img_target.display());
402                }
403                STeXDependency::ImportModule { .. }
404                | STeXDependency::UseModule { .. }
405                | STeXDependency::Module { .. } => (),
406            }
407        }
408    }
409
410    Ok(())
411}
412
413fn copy(name: &str, to: &Path) -> eyre::Result<()> {
414    let Some(sty) = tex_engine::engine::filesystem::kpathsea::KPATHSEA.which(name) else {
415        err!("No {name} found")
416    };
417    let sty_target = to.join(name);
418    err!(std::fs::copy(sty, sty_target) => "Failed to copy {}",name);
419    Ok(())
420}
421
422fn do_archive(id: &ArchiveId, target: &Path) -> eyre::Result<()> {
423    GlobalBackend.with_tree(|t| {
424        let mut steps = id.steps();
425        let Some(mut current) = steps.next() else {
426            err!("empty archive ID");
427        };
428        let mut ls = &t.top;
429        loop {
430            let Some(a) = ls.iter().find(|a| a.id().last() == current) else {
431                err!("archive not found: {id}");
432            };
433            match a {
434                ArchiveOrGroup::Archive(_) => {
435                    if steps.next().is_some() {
436                        err!("archive not found: {id}");
437                    }
438                    let Some(Archive::Local(a)) = t.get(id) else {
439                        err!("Not a local archive: {id}")
440                    };
441                    return do_manifest(a, target);
442                }
443                ArchiveOrGroup::Group(g) => {
444                    let Some(next) = steps.next() else {
445                        err!("archive not found: {id}");
446                    };
447                    current = next;
448                    ls = &g.children;
449                    if let Some(ArchiveOrGroup::Archive(a)) =
450                        g.children.iter().find(|a| a.id().is_meta())
451                    {
452                        let Some(Archive::Local(a)) = t.get(a) else {
453                            err!("archive not found: {a}");
454                        };
455                        do_manifest(a, target)?;
456                    }
457                }
458            }
459        }
460    })
461}
462
463fn do_manifest(a: &LocalArchive, target: &Path) -> eyre::Result<()> {
464    let archive_target = target.join(a.id().to_string());
465    let manifest_target = archive_target.join("META-INF/MANIFEST.MF");
466    if manifest_target.exists() {
467        return Ok(());
468    }
469    let manifest_source = a.path().join("META-INF/MANIFEST.MF");
470    if !manifest_source.exists() {
471        err!(
472            "MANIFEST.MF of {} not found (at {})",
473            a.id(),
474            manifest_source.display()
475        );
476    }
477    // safe, because by construction, file has a parent
478    let meta_inf = unsafe { manifest_target.parent().unwrap_unchecked() };
479    err!(std::fs::create_dir_all(&meta_inf) => "Failed to create directory {}",meta_inf.display());
480    err!(std::fs::copy(&manifest_source, &manifest_target) => "failed to copy {} to {}",manifest_source.display(),manifest_target.display());
481
482    let lib_source = a.path().join("lib");
483    if lib_source.exists() {
484        let lib_target = archive_target.join("lib");
485        flams_utils::fs::copy_dir_all(&lib_source, &lib_target)?;
486    }
487    Ok(())
488}
489/*
490#[cfg(test)]
491#[rstest::rstest]
492fn standalone_test() {
493    tracing_subscriber::fmt().init();
494    flams_system::settings::Settings::initialize(flams_system::settings::SettingsSpec::default());
495    flams_system::backend::GlobalBackend::initialize();
496
497    let target_dir = Path::new("/home/jazzpirate/temp/test");
498    let doc = "https://mathhub.info?a=Papers/24-cicm-views-in-alea&d=paper&l=en"
499        .parse()
500        .unwrap();
501    let file =
502        Path::new("/home/jazzpirate/work/MathHub/Papers/24-cicm-views-in-alea/source/paper.tex");
503    export_standalone(&doc, &file, target_dir).unwrap()
504}
505 */
506
507/*
508#[cfg(test)]
509#[rstest::rstest]
510fn test() {
511    fn print<T>() {
512        tracing::info!(
513            "Size of {}:{}",
514            std::any::type_name::<T>(),
515            std::mem::size_of::<T>()
516        )
517    }
518    tracing_subscriber::fmt().init();
519    print::<ArchiveId>();
520    print::<flams_ontology::uris::BaseUri>();
521    print::<flams_ontology::uris::ArchiveUri>();
522    print::<flams_ontology::uris::PathURI>();
523    print::<flams_ontology::uris::ModuleUri>();
524    print::<flams_ontology::uris::DocumentUri>();
525    print::<flams_ontology::uris::SymbolUri>();
526    print::<flams_ontology::uris::DocumentElementUri>();
527}
528 */