Skip to main content

flams_math_archives/
images.rs

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("&amp;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        // "prea", "preb", "srv" = data-ftml-src="(THIS)" "posta", "postb"
234        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                //srv.strip_circumfix("data-ftml-src=\"", "\"") {
253                s
254            } else if let Some(srv) = srv.strip_prefix("data-ftml-src=\'")
255                && let Some(s) = srv.strip_suffix("\'")
256            {
257                // srv.strip_circumfix("data-ftml-src\'", "\'") {
258                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                // TODO use correct extension
267                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}