1#![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 run: pdflatex_first
55});
56
57build_target!(PDFLATEX {
58 name: "pdflatex",
59 description: "Run pdflatex a second time",
60 run: pdflatex_second
63});
64
65build_target!(RUSTEX {
66 name: "rustex",
67 description: "Run RusTeX tex->html only",
68 run: rustex
71});
72
73pub 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 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 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 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#[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 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 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: ®ex::Captures<'_>| {
304 matched = true;
305 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 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 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 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 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