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 on_reload: || ()
36});
37
38source_format!(STEX {
39 name: "stex",
40 description: "(Semantically annotated) LaTeX",
41 targets: &[
42 PDFLATEX_FIRST.id(),
43 PDFLATEX.id(),
44 RUSTEX.id(),
45 FTML_CONTENT.id(),
46 ftml_solver::CHECK.id()
47 ],
48 file_extensions: &["tex", "ltx"],
49 dependencies: dependencies::get_deps
50});
51
52build_target!(PDFLATEX_FIRST {
53 name: "pdflatex_first",
54 description: "Run pdflatex and bibtex/biber/index once",
55 run: pdflatex_first
57});
58
59build_target!(PDFLATEX {
60 name: "pdflatex",
61 description: "Run pdflatex a second time",
62 run: pdflatex_second
65});
66
67build_target!(RUSTEX {
68 name: "rustex",
69 description: "Run RusTeX tex->html only",
70 run: rustex
73});
74
75pub struct PDF(PathBuf);
78impl FileArtifact for PDF {
79 fn kind(&self) -> &'static str {
80 "pdf"
81 }
82 fn as_any(&self) -> &dyn std::any::Any {
83 self as _
84 }
85 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
86 self as _
87 }
88 fn source(&self) -> &Path {
89 &self.0
90 }
91}
92
93#[allow(clippy::needless_pass_by_value)]
94fn pdflatex_first(task: BuildSpec) -> BuildResult {
95 let Either::Left(path) = task.source else {
96 return BuildResult {
97 log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
98 result: Err(Vec::new()),
99 };
100 };
101 latex::clean(path);
102 let log = path.with_extension("log");
103 let mh = task
104 .backend
105 .mathhubs()
106 .map(|p| p.display().to_string())
107 .collect::<Vec<_>>()
108 .join(",");
109 let ret = latex::pdflatex_and_bib(path, [("STEX_WRITESMS", "true"), ("MATHHUB", &mh)]);
110 if ret.is_ok() {
111 BuildResult {
112 log: FileOrString::File(log),
113 result: Ok(Some(Box::new(PDF(path.with_extension("pdf"))) as _)),
114 }
115 } else {
116 BuildResult {
117 log: FileOrString::File(log),
118 result: Err(Vec::new()),
119 }
120 }
121}
122
123#[allow(clippy::needless_pass_by_value)]
124fn pdflatex_second(task: BuildSpec) -> BuildResult {
125 let Either::Left(path) = task.source else {
126 return BuildResult {
127 log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
128 result: Err(Vec::new()),
129 };
130 };
131 let log = path.with_extension("log");
132 let mh = task
133 .backend
134 .mathhubs()
135 .map(|p| p.display().to_string())
136 .collect::<Vec<_>>()
137 .join(",");
138 let ret = latex::pdflatex(path, [("STEX_USESMS", "true"), ("MATHHUB", &mh)]);
139 if ret.is_ok() {
140 BuildResult {
141 log: FileOrString::File(log),
142 result: Ok(Some(Box::new(PDF(path.with_extension("pdf"))) as _)),
143 }
144 } else {
145 BuildResult {
146 log: FileOrString::File(log),
147 result: Err(Vec::new()),
148 }
149 }
150}
151
152#[allow(clippy::needless_pass_by_value)]
153fn rustex(task: BuildSpec) -> BuildResult {
154 let Either::Left(path) = task.source else {
156 return BuildResult {
157 log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
158 result: Err(Vec::new()),
159 };
160 };
161 let out = path.with_extension("rlog");
162 let ocl = out.clone();
163 let mh = task
164 .backend
165 .mathhubs()
166 .map(|p| p.display().to_string())
167 .collect::<Vec<_>>()
168 .join(",");
169 let run = move || {
170 RusTeX::get()
172 .map_err(|()| "Could not initialize RusTeX".to_string())
173 .and_then(|e| {
174 std::panic::catch_unwind(move || {
175 e.run_with_envs(
176 path,
177 false,
178 [
179 ("STEX_USESMS".to_string(), "true".to_string()),
180 ("MATHHUB".to_string(), mh),
181 ],
182 Some(&ocl),
183 )
184 })
185 .map_err(|e| {
186 #[allow(clippy::option_if_let_else)]
187 if let Some(s) = e.downcast_ref::<&str>() {
188 (*s).to_string()
189 } else if let Ok(s) = e.downcast::<String>() {
190 *s
191 } else {
192 "Unknown RusTeX error".to_string()
193 }
194 })
195 })
196 };
197 #[cfg(debug_assertions)]
198 let ret = {
199 std::thread::scope(move |s| {
200 std::thread::Builder::new()
201 .stack_size(16 * 1024 * 1024)
202 .spawn_scoped(s, run)
203 .expect("foo")
204 .join()
205 .expect("foo")
206 })
207 };
208 #[cfg(not(debug_assertions))]
209 let ret = { run() };
210 match ret {
212 Err(s) => BuildResult {
213 log: FileOrString::Str(s.into_boxed_str()),
214 result: Err(Vec::new()),
215 },
216 Ok(Err(_)) => BuildResult {
217 log: FileOrString::File(out),
218 result: Err(Vec::new()),
219 },
220 Ok(Ok(s)) => {
221 latex::clean(path);
222 BuildResult {
223 log: FileOrString::File(out),
224 result: Ok(Some(Box::new(FtmlString(s.into_boxed_str())) as _)),
225 }
226 }
227 }
228}
229
230static OPTIONS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(||
231 unsafe{ regex::Regex::new(
233 r"\\(?<cmd>documentclass|usepackage|RequirePackage)(?<opts>\[[^\]]*\])?\{(?<name>notesslides|stex|hwexam|problem)\}"
234 ).unwrap_unchecked() });
235
236#[allow(clippy::trivial_regex)]
237static LIBS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(||
238 unsafe{ regex::Regex::new(r"\\libinput\{").unwrap_unchecked() });
240
241macro_rules! err {
242 ($fmt:expr) => {return Err(eyre::eyre!($fmt))};
243 ($fmt:expr, $($args:tt)*) => {return Err(eyre::eyre!($fmt,$($args)*))};
244 ($e:expr => $fmt:expr) => { $e.wrap_err($fmt)?};
245 ($e:expr => $fmt:expr, $($args:tt)*) => { $e.wrap_err_with(|| format!($fmt,$($args)*))?};
246}
247
248#[allow(clippy::too_many_lines)]
250pub fn export_standalone(doc: &DocumentUri, file: &Path, target_dir: &Path) -> eyre::Result<()> {
251 use std::fmt::Write;
252 if !file.extension().is_some_and(|e| e == "tex") {
253 err!("Not a .tex file: {}", file.display());
254 }
255
256 let file_name = unsafe { file.file_name().unwrap_unchecked() };
258
259 let mh_path = target_dir.join("mathhub");
260 err!(
261 std::fs::create_dir_all(&mh_path) =>
262 "Invalid target directory: {}",
263 mh_path.display()
264 );
265 let archive = doc.archive_id();
266
267 let mh = flams_math_archives::mathhub::mathhubs()
268 .iter()
269 .map(|p| p.display().to_string())
270 .collect::<Vec<_>>()
271 .join(",");
272 let Ok(()) = latex::pdflatex_and_bib(file, [("STEX_WRITESMS", "true"), ("MATHHUB", &mh)])
273 else {
274 err!(
275 "failed to build {}\nCheck .log file for details",
276 file.display()
277 );
278 };
279
280 let sms = file.with_extension("sms");
281 let sms_target = target_dir.join(file_name).with_extension("sms");
282 err!(std::fs::copy(&sms, &sms_target) => "Failed to copy file {}",sms.display() );
283
284 let orig_txt = err!(
285 std::fs::read_to_string(file) =>
286 "failed to open file {}",
287 file.display()
288 );
289 let Some(begin) = orig_txt.find("\\begin{document}") else {
290 err!("No \\begin{{document}} found!")
291 };
292 let mut txt = orig_txt[..begin].to_string();
293 let rel_path = doc.path().map_or_else(
295 || file_name.display().to_string(),
296 |p| format!("{p}/{}", file_name.display()),
297 );
298 err!(
299 write!(
300 txt,
301 "\n\\begin{{document}}\n \\inputref[{archive}]{{{rel_path}}}\n\\end{{document}}"
302 ) =>
303 "impossible",
304 );
305
306 let mut matched = false;
307 let txt = OPTIONS.replace(&txt, |cap: ®ex::Captures<'_>| {
308 matched = true;
309 let (cmd, name) = unsafe {
312 (
313 cap.name("cmd").unwrap_unchecked().as_str(),
314 cap.name("name").unwrap_unchecked().as_str(),
315 )
316 };
317 cap.name("opts").map_or_else(
318 || format!("\\{cmd}[mathhub=./mathhub,usesms]{{{name}}}"),
319 |opts| {
320 format!(
321 "\\{cmd}[{},mathhub=./mathhub,usesms]{{{name}}}",
322 &opts.as_str()[1..opts.as_str().len() - 1]
323 )
324 },
325 )
326 });
327 if !matched {
328 err!(
329 "No sTeX \\documentclass or \\package found in {}",
330 file.display()
331 );
332 }
333 let rep = format!("\\libinput[{archive}]{{");
334 let txt = LIBS.replace_all(&txt, &rep);
335
336 let tex_target = target_dir.join(file_name);
337 err!(std::fs::write(&tex_target, txt.as_bytes()) => "Failed to write to file {}",tex_target.display());
338
339 copy("stex.sty", &target_dir)?;
340 copy("stex-logo.sty", &target_dir)?;
341 copy("stex-backend-pdflatex.cfg", &target_dir)?;
342 copy("stex-highlighting.sty", &target_dir)?;
343 copy("stexthm.sty", &target_dir)?;
344 let mut todos = vec![(orig_txt, file.to_owned(), doc.clone())];
347 let mut archives = VecSet(Vec::with_capacity(4));
348 while let Some((txt, f, d)) = todos.pop() {
349 if !archives.0.contains(d.archive_id()) {
350 archives.0.push(d.archive_id().clone());
351 do_archive(d.archive_id(), &mh_path)?;
352 }
353 let name = unsafe { f.file_name().unwrap_unchecked() };
355 let target_file = d.path().map_or_else(
356 || mh_path.join(d.archive_id().to_string()).join("source"),
357 |p| {
358 mh_path
359 .join(d.archive_id().to_string())
360 .join("source")
361 .join(p.to_string())
362 },
363 );
364 err!(std::fs::create_dir_all(&target_file) => "Failed to create directory {}",target_file.display());
365 let target_file = target_file.join(name);
366 err!(std::fs::copy(&f, target_file) => "Failed to copy file {}",f.display());
367 for dep in dependencies::parse_deps(&txt, &f, &d, &AnyBackend::Global) {
368 match dep {
369 STeXDependency::Inputref { archive, filepath } => {
370 let archive = archive.as_ref().unwrap_or(&d.path.archive.id);
371 let Some((d, f)) = GlobalBackend.with_local_archive(archive, |a| {
372 a.and_then(|a| {
373 let f = a.source_dir().join(&*filepath);
374 let d = DocumentUri::from_archive_relpath(a.uri().clone(), &*filepath)
375 .ok()?;
376 Some((d, f))
377 })
378 }) else {
379 err!("Could not find document for file {}", f.display())
380 };
381 let txt = err!(
382 std::fs::read_to_string(&f) =>
383 "failed to open file {}",
384 f.display()
385 );
386 todos.push((txt, f, d));
387 }
388 STeXDependency::Img { archive, filepath } => {
389 let archive = archive.as_ref().unwrap_or(&d.path.archive.id);
390 let Some(source) = GlobalBackend.with_local_archive(archive, |a| {
391 a.map(|a| a.path().join("source").join(&*filepath))
392 }) else {
393 err!("Could not find image file {}", f.display())
394 };
395 let img_target = mh_path
396 .join(archive.to_string())
397 .join("source")
398 .join(&*filepath);
399 if !source.exists() {
400 err!("img file not found: {}", source.display())
401 }
402 let parent = unsafe { img_target.parent().unwrap_unchecked() };
404 err!(std::fs::create_dir_all(&parent) => "Error creating directory {}",parent.display());
405 err!(std::fs::copy(&source,&img_target) => "Error copying {}",img_target.display());
406 }
407 STeXDependency::ImportModule { .. }
408 | STeXDependency::UseModule { .. }
409 | STeXDependency::Module { .. } => (),
410 }
411 }
412 }
413
414 Ok(())
415}
416
417fn copy(name: &str, to: &Path) -> eyre::Result<()> {
418 let Some(sty) = tex_engine::engine::filesystem::kpathsea::KPATHSEA.which(name) else {
419 err!("No {name} found")
420 };
421 let sty_target = to.join(name);
422 err!(std::fs::copy(sty, sty_target) => "Failed to copy {}",name);
423 Ok(())
424}
425
426fn do_archive(id: &ArchiveId, target: &Path) -> eyre::Result<()> {
427 GlobalBackend.with_tree(|t| {
428 let mut steps = id.steps();
429 let Some(mut current) = steps.next() else {
430 err!("empty archive ID");
431 };
432 let mut ls = &t.top;
433 loop {
434 let Some(a) = ls.iter().find(|a| a.id().last() == current) else {
435 err!("archive not found: {id}");
436 };
437 match a {
438 ArchiveOrGroup::Archive(_) => {
439 if steps.next().is_some() {
440 err!("archive not found: {id}");
441 }
442 let Some(Archive::Local(a)) = t.get(id) else {
443 err!("Not a local archive: {id}")
444 };
445 return do_manifest(a, target);
446 }
447 ArchiveOrGroup::Group(g) => {
448 let Some(next) = steps.next() else {
449 err!("archive not found: {id}");
450 };
451 current = next;
452 ls = &g.children;
453 if let Some(ArchiveOrGroup::Archive(a)) =
454 g.children.iter().find(|a| a.id().is_meta())
455 {
456 let Some(Archive::Local(a)) = t.get(a) else {
457 err!("archive not found: {a}");
458 };
459 do_manifest(a, target)?;
460 }
461 }
462 }
463 }
464 })
465}
466
467fn do_manifest(a: &LocalArchive, target: &Path) -> eyre::Result<()> {
468 let archive_target = target.join(a.id().to_string());
469 let manifest_target = archive_target.join("META-INF/MANIFEST.MF");
470 if manifest_target.exists() {
471 return Ok(());
472 }
473 let manifest_source = a.path().join("META-INF/MANIFEST.MF");
474 if !manifest_source.exists() {
475 err!(
476 "MANIFEST.MF of {} not found (at {})",
477 a.id(),
478 manifest_source.display()
479 );
480 }
481 let meta_inf = unsafe { manifest_target.parent().unwrap_unchecked() };
483 err!(std::fs::create_dir_all(&meta_inf) => "Failed to create directory {}",meta_inf.display());
484 err!(std::fs::copy(&manifest_source, &manifest_target) => "failed to copy {} to {}",manifest_source.display(),manifest_target.display());
485
486 let lib_source = a.path().join("lib");
487 if lib_source.exists() {
488 let lib_target = archive_target.join("lib");
489 flams_utils::fs::copy_dir_all(&lib_source, &lib_target)?;
490 }
491 Ok(())
492}
493