1use crate::{
2 MathArchive,
3 backend::{GlobalBackend, LocalBackend},
4};
5use ftml_ontology::{
6 narrative::elements::DocumentElementRef,
7 utils::{Css, RefTree},
8};
9use ftml_uris::{ArchiveId, DocumentUri};
10use std::{
11 borrow::Cow,
12 path::{Path, PathBuf},
13};
14
15#[derive(Clone, Debug, Hash, PartialEq, Eq)]
16pub enum ImagePath<'s> {
17 Kpse(&'s str),
18 ArchiveRelpath { id: ArchiveId, rel_path: &'s str },
19 File(&'s Path),
20}
21
22impl<'s> ImagePath<'s> {
23 #[must_use]
24 pub fn to_path(self) -> Option<PathBuf> {
25 let r = match self {
26 Self::Kpse(p) => tex_engine::engine::filesystem::kpathsea::KPATHSEA.which(p),
27 Self::ArchiveRelpath { id, rel_path } => {
28 GlobalBackend.with_local_archive(&id, |a| a.map(|a| a.path().join(&*rel_path)))
29 }
30 Self::File(p) => Some(p.into()),
31 }?;
32 if r.extension().is_some_and(|r| r == "pdf") {
33 Some(r.with_extension("pdf-rustex.png"))
34 } else {
35 Some(r)
36 }
37 }
38 #[must_use]
39 pub fn get_webp(path: &Path) -> Option<(Box<[u8]>, &str)> {
40 static NO_WEBP: &[&str] = &["svg"];
41 if let Some(ext) = path.extension().and_then(|s| s.to_str())
42 && NO_WEBP.contains(&ext)
43 {
44 std::fs::read(path)
45 .ok()
46 .map(|v| (v.into_boxed_slice(), ext))
47 } else {
48 let img = match image::ImageReader::open(path) {
49 Ok(img) => img,
50 Err(e) => {
51 tracing::error!("Failed to open {}: {e}", path.display());
52 return None;
53 }
54 };
55 let img = match img.decode() {
56 Ok(img) => img,
57 Err(e) => {
58 tracing::error!("Failed to decode image {}: {e}", path.display());
59 return None;
60 }
61 };
62 let mut v = Vec::<u8>::new();
63 if let Err(e) =
64 img.write_with_encoder(image::codecs::webp::WebPEncoder::new_lossless(&mut v))
65 {
66 tracing::error!("Failed to encode {} to .webp: {e}", path.display());
67 return None;
68 }
69 Some((v.into_boxed_slice(), "webp"))
70 }
71 }
72 #[must_use]
73 pub fn get(self) -> Option<(Box<[u8]>, String)> {
74 Self::get_webp(&self.to_path()?).map(|(b, e)| (b, e.to_string()))
75 }
76 #[must_use]
77 #[allow(clippy::option_if_let_else)]
78 pub fn from_query(s: &'s str, allow_file: bool) -> Option<Self> {
79 if let Some(s) = s.strip_prefix("kpse=") {
80 Some(Self::Kpse(s))
81 } else if let Some(f) = s.strip_prefix("file=")
82 && allow_file
83 {
84 Some(Self::File(Path::new(f)))
85 } else if let Some(s) = s.strip_prefix("a=")
86 && let Some((a, rp)) = s.split_once("&rp=")
87 {
88 let a = a.parse().unwrap_or_else(|_| unreachable!());
89 Some(Self::ArchiveRelpath {
90 id: a,
91 rel_path: rp,
92 })
93 } else if let Some(s) = s.strip_prefix("a=")
94 && let Some((a, rp)) = s.split_once("&rp=")
95 {
96 let a = a.parse().unwrap_or_else(|_| unreachable!());
97 Some(Self::ArchiveRelpath {
98 id: a,
99 rel_path: rp,
100 })
101 } else {
102 None
103 }
104 }
105}
106
107struct ExportData<'s> {
108 images: &'s mut Vec<Box<Path>>,
109 aux_path: &'s Path,
110 img_path: &'s Path,
111 css_path: &'s Path,
112 css: &'s mut Vec<Box<str>>,
113 all_documents: &'s mut Vec<DocumentUri>,
114 backend: &'s dyn LocalBackend,
115}
116
117pub(crate) fn html_export(
118 uri: &DocumentUri,
119 to: &Path,
120 backend: &dyn LocalBackend,
121) -> Result<(), String> {
122 use std::fmt::Write as SW;
123 use std::io::Write;
124 let mut images = Vec::new();
125 let mut all_documents = Vec::new();
126 let inputs = to.join("aux");
127 let img_path = to.join("img");
128 let css_path = to.join("css");
129 std::fs::create_dir_all(&inputs).map_err(|e| e.to_string())?;
130 std::fs::create_dir_all(&img_path).map_err(|e| e.to_string())?;
131 std::fs::create_dir_all(&css_path).map_err(|e| e.to_string())?;
132 let mut css = Vec::new();
133 let mut data = ExportData {
134 images: &mut images,
135 aux_path: &inputs,
136 img_path: &img_path,
137 css_path: &css_path,
138 css: &mut css,
139 all_documents: &mut all_documents,
140 backend,
141 };
142 recurse_export(uri, &mut data)?;
143
144 let htmlstr = backend.get_html_full(uri).map_err(|e| e.to_string())?;
145 let htmlstr = subst_img(htmlstr, &mut images, &img_path)?.into_string();
146 let mut replaces = String::new();
147 for (i, u) in all_documents.into_iter().enumerate() {
148 if replaces.is_empty() {
149 replaces.push_str(",\nredirects:[");
150 } else {
151 replaces.push(',');
152 }
153 let _ = write!(&mut replaces, "[\"{u}\",\"aux/{i}.json\"]");
154 }
155 if !replaces.is_empty() {
156 replaces.push(']');
157 }
158 let htmlstr = htmlstr.replace(
159 "</head>",
160 &format!(
161 r#"
162 <script type="text/javascript" id="ftml">
163 window.FTML_CONFIG = {{
164 documentUri:"{uri}",
165 backendUrl:"https://mathhub.info",
166 logLevel:"WARN"
167 {replaces}
168 }};
169 </script>
170 <script src="https://mathhub.info/ftml.js"></script>
171 </head>"#
172 ),
173 );
174 let index = to.join("index.html");
175 let mut out = std::fs::File::create_new(index).map_err(|e| e.to_string())?;
176 out.write_all(htmlstr.as_bytes())
177 .map_err(|e| e.to_string())?;
178 out.flush().map_err(|e| e.to_string())?;
179 Ok(())
180}
181fn recurse_export(uri: &DocumentUri, data: &mut ExportData) -> Result<(), String> {
182 data.all_documents.push(uri.clone());
183 let doc = data.backend.get_document(uri).map_err(|e| e.to_string())?;
184 for e in doc.dfs() {
185 if let DocumentElementRef::DocumentReference { target, .. } = e
186 && !data.all_documents.contains(target)
187 {
188 let idx = data.all_documents.len();
189 let path = data.aux_path.join(format!("{idx}.json"));
190 do_document(target, &path, data)?;
191 recurse_export(target, data)?;
192 }
193 }
194 Ok(())
195}
196
197fn do_document(uri: &DocumentUri, path: &Path, data: &mut ExportData) -> Result<(), String> {
198 let (mut css, htmlstr) = data.backend.get_html_body(uri).map_err(|e| e.to_string())?;
199 let htmlstr = subst_img(htmlstr, data.images, data.img_path)?;
200 for c in &mut css {
201 if let Css::Link(opath) = c
202 && let Some(path) = opath.strip_prefix("srv:/")
203 {
204 let idx = if let Some(i) = data.css.iter().position(|p| **p == *path) {
205 format!("./css/{i}.css")
206 } else {
207 let i = data.css.len();
208 data.css.push(path.into());
209 let f = std::env::current_exe()
210 .map_err(|e| e.to_string())?
211 .parent()
212 .ok_or_else(|| "error getting parent directory of executable".to_string())?
213 .join("web")
214 .join(path);
215 let target = data.css_path.join(format!("{i}.css"));
216 std::fs::copy(f, target).map_err(|e| e.to_string())?;
217 format!("{i}.css")
218 };
219 *opath = idx.into_boxed_str();
220 }
221 }
222 let out = std::fs::File::create_new(path).map_err(|e| e.to_string())?;
223 serde_json::to_writer(std::io::BufWriter::new(out), &(uri, css, htmlstr))
224 .map_err(|e| e.to_string())
225}
226
227fn subst_img(
228 htmlstr: Box<str>,
229 images: &mut Vec<Box<Path>>,
230 img_path: &Path,
231) -> Result<Box<str>, String> {
232 thread_local! {
233 static REGEX: fancy_regex::Regex = fancy_regex::Regex::new(r#"<img\s+(?<prea>(?:(?!src=[\"\'])[^>])*)(?:src=[\"\'][\"\'])?(?<preb>[^>]*)(?<srv>data-ftml-src=[\"\']srv:\/img\?[^\"\']*[\"\'])(?<posta>(?:(?!src=[\"\'])[^>])*)(?:src=[\"\'][\"\'])?(?<postb>[^>]*)>"#).expect("this is a bug");
235 }
236 let mut failed: Option<String> = None;
237 let cow = REGEX.with(|regex| {
238 regex.replace_all(&htmlstr, |cap: &fancy_regex::Captures<str>| {
239 macro_rules! ret {
240 ($name:pat = $e:expr) => {
241 let Some($name) = $e else { ret!() };
242 };
243 () => {{
244 failed = Some("invalid img tag in html string".to_string());
245 return Cow::Borrowed("");
246 }};
247 }
248 let srv = &cap["srv"];
249 let capurl = if let Some(srv) = srv.strip_prefix("data-ftml-src=\"")
250 && let Some(s) = srv.strip_suffix("\"")
251 {
252 s
254 } else if let Some(srv) = srv.strip_prefix("data-ftml-src=\'")
255 && let Some(s) = srv.strip_suffix("\'")
256 {
257 s
259 } else {
260 ret!();
261 };
262 ret!(capurl = capurl.strip_prefix("srv:/img?"));
263 ret!(img = ImagePath::from_query(capurl, true));
264 ret!(img = img.to_path());
265 let filestr = if let Some(i) = images.iter().position(|p| **p == *img) {
266 format!("./img/{i}.webp")
268 } else {
269 ret!((imgbytes, ext) = ImagePath::get_webp(&img));
270 let i = images.len();
271 let out_file = img_path.join(format!("{i}.{ext}"));
272 if let Err(e) = std::fs::write(out_file, imgbytes) {
273 failed = Some(e.to_string());
274 return Cow::Borrowed("");
275 }
276 let s = format!("./img/{i}.{ext}");
277 images.push(img.into_boxed_path());
278 s
279 };
280 let pre_a = cap.name("prea").map_or("", |c| c.as_str());
281 let pre_b = cap.name("preb").map_or("", |c| c.as_str());
282 let post_a = cap.name("posta").map_or("", |c| c.as_str());
283 let post_b = cap.name("postb").map_or("", |c| c.as_str());
284 Cow::Owned(format!(
285 "<img {pre_a}{pre_b}src=\"{filestr}\"{post_a}{post_b}>"
286 ))
287 })
288 });
289 if let Some(e) = failed {
290 return Err(e);
291 }
292 match cow {
293 Cow::Borrowed(_) => Ok(htmlstr),
294 Cow::Owned(s) => Ok(s.into_boxed_str()),
295 }
296}