Skip to main content

tpnote_lib/
markup_language.rs

1//! Helper functions dealing with markup languages.
2use crate::config::EmbeddedContentErrorPolicy;
3use crate::config::LIB_CFG;
4use crate::error::NoteError;
5#[cfg(feature = "renderer")]
6use crate::highlight::SyntaxPreprocessor;
7#[cfg(feature = "renderer")]
8use crate::html2md::convert_html_to_md;
9use crate::settings::SETTINGS;
10use parse_hyperlinks::renderer::text_links2html;
11use parse_hyperlinks::renderer::text_rawlinks2html;
12#[cfg(feature = "renderer")]
13use pulldown_cmark::{Options, Parser, html};
14#[cfg(feature = "renderer")]
15use rst_parser;
16#[cfg(feature = "renderer")]
17use rst_renderer;
18use serde::{Deserialize, Serialize};
19use std::path::Path;
20#[cfg(feature = "renderer")]
21use std::str::from_utf8;
22
23/// The filter `filter_tags()` omits HTML `<span....>` after converting to
24/// Markdown.
25#[cfg(test)] // Currently the `filter_tags()` filter is not used in the code.
26#[cfg(feature = "renderer")]
27const FILTERED_TAGS: &[&str; 4] = &["<span", "</span>", "<div", "</div>"];
28
29/// Available converters for converting the input from standard input or the
30/// clipboard to HTML.
31#[non_exhaustive]
32#[derive(Default, Debug, Hash, Clone, Eq, PartialEq, Deserialize, Serialize, Copy)]
33pub enum InputConverter {
34    /// Convert from HTML to Markdown.
35    ToMarkdown,
36    /// Do not convert, return an error instead.
37    #[default]
38    Disabled,
39    /// Do not convert, just pass through wrapped in `Ok()`.
40    PassThrough,
41}
42
43impl InputConverter {
44    /// Returns a function that implements the `InputConverter` looked up in
45    /// the `extensions` table in the `extension` line.
46    /// When `extension` is not found in `extensions`, the function returns
47    /// a `NoteError`.
48    #[inline]
49    pub(crate) fn build(extension: &str) -> fn(String) -> Result<String, NoteError> {
50        let settings = SETTINGS.read_recursive();
51        let scheme = &LIB_CFG.read_recursive().scheme[settings.current_scheme];
52
53        let mut input_converter = InputConverter::default();
54        for e in &scheme.filename.extensions {
55            if e.0 == *extension {
56                input_converter = e.1;
57                break;
58            }
59        }
60
61        match input_converter {
62            #[cfg(feature = "renderer")]
63            InputConverter::ToMarkdown => |s| convert_html_to_md(&s),
64
65            InputConverter::Disabled => {
66                |_: String| -> Result<String, NoteError> { Err(NoteError::HtmlToMarkupDisabled) }
67            }
68
69            _ => Ok,
70        }
71    }
72
73    /// Filters the `TARGET_TAGS`, e.g. `<span...>`, `</span>`, `<div...>`
74    /// and `<div>` in `text`.
75    /// Contract: the input substring `...` does not contain the characters
76    /// `>` or `\n`.
77    #[cfg(test)] // Currently the `filter_tags()` filter is not used in the code.
78    #[cfg(feature = "renderer")]
79    fn filter_tags(text: String) -> String {
80        let mut res = String::new();
81        let mut i = 0;
82        while let Some(mut start) = text[i..].find('<') {
83            if let Some(mut end) = text[i + start..].find('>') {
84                end += 1;
85                // Move on if there is another opening bracket.
86                if let Some(new_start) = text[i + start + 1..i + start + end].rfind('<') {
87                    start += new_start + 1;
88                    end -= new_start + 1;
89                }
90
91                // Is this a tag listed in `FILTERED_TAGS`?
92                let filter_tag = FILTERED_TAGS
93                    .iter()
94                    .any(|&pat| text[i + start..i + start + end].starts_with(pat));
95
96                if filter_tag {
97                    res.push_str(&text[i..i + start]);
98                } else {
99                    res.push_str(&text[i..i + start + end]);
100                };
101                i = i + start + end;
102            } else {
103                res.push_str(&text[i..i + start + 1]);
104                i = i + start + 1;
105            }
106        }
107        if i > 0 {
108            res.push_str(&text[i..]);
109            if res != text {
110                log::trace!("`html_to_markup` filter: removed tags in \"{}\"", text);
111            }
112            res
113        } else {
114            text
115        }
116    }
117}
118
119/// The Markup language of the note content.
120#[non_exhaustive]
121#[derive(Default, Debug, Hash, Clone, Eq, PartialEq, Deserialize, Serialize, Copy)]
122pub enum MarkupLanguage {
123    Markdown,
124    ReStructuredText,
125    Html,
126    PlainText,
127    /// The markup language is known, but the renderer is disabled.
128    RendererDisabled,
129    /// This is a Tp-Note file, but we are not able to determine the
130    /// MarkupLanguage at this point.
131    Unkown,
132    /// This is not a Tp-Note file.
133    #[default]
134    None,
135}
136
137impl MarkupLanguage {
138    /// If `Self` is `None` return `rhs`, otherwise return `Self`.
139    pub fn or(self, rhs: Self) -> Self {
140        match self {
141            MarkupLanguage::None => rhs,
142            _ => self,
143        }
144    }
145
146    /// Returns the MIME type for all `Markup Languages.is_tpnote_file()==true`.
147    /// Otherwise, for `MarkupLanguage::None` this returns None.
148    pub fn mine_type(&self) -> Option<&'static str> {
149        match self {
150            Self::Markdown => Some("text/markodwn"),
151            Self::ReStructuredText => Some("x-rst"),
152            Self::Html => Some("text/html"),
153            Self::PlainText => Some("text/plain"),
154            Self::RendererDisabled => Some("text/plain"),
155            Self::Unkown => Some("text/plain"),
156            _ => None,
157        }
158    }
159
160    /// As we identify a markup language by the file's extension, we
161    /// can also tell, in case `Markuplanguage::from(ext).is_some()`,
162    /// that a file with the extension `ext` is a Tp-Note file.
163    pub fn is_some(&self) -> bool {
164        !matches!(self, Self::None)
165    }
166
167    /// As we identify a markup language by the file's extension, we
168    /// can also tell, in case `Markuplanguage::from(ext).is_none()`,
169    /// that a file with the extension `ext` is NOT a Tp-Note file.
170    pub fn is_none(&self) -> bool {
171        matches!(self, Self::None)
172    }
173
174    /// Every `MarkupLanguage` variant has an own internal HTML renderer:
175    /// * `Markdown` is rendered according the "CommonMark" standard.
176    /// * Currently only as small subset of ReStructuredText is rendered for
177    ///   `ReStructuredText`. This feature is experimental.
178    /// * The `Html` renderer simply forwards the input without modification.
179    /// * `PlainText` is rendered as raw text. Hyperlinks in Markdown,
180    ///   ReStructuredText, AsciiDoc and WikiText syntax are detected and
181    ///   are displayed in the rendition with their link text. All hyperlinks
182    ///   are clickable.
183    /// * `Unknown` is rendered like `PlainText`, hyperlinks are also
184    ///   clickable, but they are displayed as they appear in the input.
185    /// * For the variant `None` the result is always the empty string whatever
186    ///   the input may be.
187    ///
188    /// `error_policy` governs how embedded rendered content (e.g. Mermaid
189    /// diagrams or LaTeX formulas) that fails to render is surfaced. It is only
190    /// consulted by the `Markdown` renderer; all other variants ignore it.
191    #[cfg_attr(not(feature = "renderer"), allow(unused_variables))]
192    pub fn render(
193        &self,
194        input: &str,
195        error_policy: EmbeddedContentErrorPolicy,
196    ) -> Result<String, NoteError> {
197        match self {
198            #[cfg(feature = "renderer")]
199            Self::Markdown => {
200                // Set up options and parser. Besides the CommonMark standard
201                // we enable some useful extras.
202
203                let options = Options::all();
204                let parser = Parser::new_ext(input, options);
205                let parser = SyntaxPreprocessor::new(parser, error_policy);
206
207                // A failed embedded renderer under the `HardError` policy stores
208                // its `NoteError` in this sink (the iterator cannot return an
209                // error). Grab a handle before consuming the parser.
210                #[cfg(any(feature = "mermaid", feature = "latex"))]
211                let err_sink = parser.error_sink();
212
213                // Write to String buffer.
214                let mut html_output: String = String::with_capacity(input.len() * 3 / 2);
215                html::push_html(&mut html_output, parser);
216
217                // Bubble up a `HardError` captured during rendering.
218                #[cfg(any(feature = "mermaid", feature = "latex"))]
219                if let Some(e) = err_sink.borrow_mut().take() {
220                    return Err(e);
221                }
222
223                Ok(html_output)
224            }
225
226            #[cfg(feature = "renderer")]
227            Self::ReStructuredText => {
228                // Note, that the current ReStructuredText renderer requires
229                // files to end with no new line.
230                let rest_input = input.trim();
231                // Write to String buffer.
232                let mut html_output: Vec<u8> = Vec::with_capacity(rest_input.len() * 3 / 2);
233                const STANDALONE: bool = false; // Don't wrap in `<!doctype html><html></html>`.
234                let doc = rst_parser::parse(rest_input.trim_start())
235                    .map_err(|e| NoteError::RstParse { msg: e.to_string() })?;
236                rst_renderer::render_html(&doc, &mut html_output, STANDALONE)
237                    .map_err(|e| NoteError::RstParse { msg: e.to_string() })?;
238                Ok(from_utf8(&html_output).unwrap_or_default().to_string())
239            }
240
241            Self::Html => Ok(input.to_string()),
242
243            Self::PlainText | Self::RendererDisabled => Ok(text_links2html(input)),
244
245            Self::Unkown => Ok(text_rawlinks2html(input)),
246
247            _ => Ok(String::new()),
248        }
249    }
250}
251
252impl From<&Path> for MarkupLanguage {
253    /// Is the file extension ` at the end of the given path listed in
254    /// `file.extensions`? Return the corresponding `MarkupLanguage`.
255    /// Only the extension of `Path` is considered here.
256    #[inline]
257    fn from(path: &Path) -> Self {
258        let file_extension = path
259            .extension()
260            .unwrap_or_default()
261            .to_str()
262            .unwrap_or_default();
263
264        Self::from(file_extension)
265    }
266}
267
268impl From<&str> for MarkupLanguage {
269    /// Is `file_extension` listed in `file.extensions`?
270    #[inline]
271    fn from(file_extension: &str) -> Self {
272        let scheme = &LIB_CFG.read_recursive().scheme[SETTINGS.read_recursive().current_scheme];
273
274        for e in &scheme.filename.extensions {
275            if e.0 == file_extension {
276                return e.2;
277            }
278        }
279
280        // Nothing was found.
281        MarkupLanguage::None
282    }
283}
284
285#[cfg(test)]
286mod tests {
287
288    use super::InputConverter;
289    use super::MarkupLanguage;
290    #[cfg(any(feature = "mermaid", feature = "latex"))]
291    use crate::config::EmbeddedContentErrorPolicy;
292    use std::path::Path;
293
294    #[test]
295    fn test_markuplanguage_from() {
296        //
297        let path = Path::new("/dir/file.md");
298        assert_eq!(MarkupLanguage::from(path), MarkupLanguage::Markdown);
299
300        //
301        let path = Path::new("md");
302        assert_eq!(MarkupLanguage::from(path), MarkupLanguage::None);
303        //
304        let ext = "/dir/file.md";
305        assert_eq!(MarkupLanguage::from(ext), MarkupLanguage::None);
306
307        //
308        let ext = "md";
309        assert_eq!(MarkupLanguage::from(ext), MarkupLanguage::Markdown);
310
311        //
312        let ext = "rst";
313        assert_eq!(MarkupLanguage::from(ext), MarkupLanguage::ReStructuredText);
314    }
315
316    #[test]
317    fn test_markuplanguage_render() {
318        // Markdown
319        let input = "[Link text](https://domain.invalid/)";
320        let expected: &str = "<p><a href=\"https://domain.invalid/\">Link text</a></p>\n";
321
322        let result = MarkupLanguage::Markdown
323            .render(input, Default::default())
324            .unwrap();
325        assert_eq!(result, expected);
326
327        // ReStructuredText
328        let input = "`Link text <https://domain.invalid/>`_";
329        let expected: &str = "<p><a href=\"https://domain.invalid/\">Link text</a></p>";
330
331        let result = MarkupLanguage::ReStructuredText
332            .render(input, Default::default())
333            .unwrap();
334        assert_eq!(result, expected);
335    }
336
337    #[cfg(feature = "mermaid")]
338    #[test]
339    fn test_mermaid_error_policy() {
340        let input = "```mermaid\nthis is not a valid mermaid diagram !!!\n```";
341
342        // `HardError`: a malformed diagram aborts the whole rendition.
343        let result = MarkupLanguage::Markdown.render(input, EmbeddedContentErrorPolicy::HardError);
344        assert!(result.is_err());
345
346        // `Inline`: the note still renders, carrying an inline error box.
347        let result = MarkupLanguage::Markdown
348            .render(input, EmbeddedContentErrorPolicy::Inline)
349            .unwrap();
350        assert!(result.contains("mermaid-error"));
351    }
352
353    #[cfg(feature = "latex")]
354    #[test]
355    fn test_latex_error_policy() {
356        // `\left( x` is unbalanced and makes `latex2mathml` return `Err`.
357        let input = "```math\n\\left( x\n```";
358
359        // `HardError`: a malformed formula aborts the whole rendition.
360        let result = MarkupLanguage::Markdown.render(input, EmbeddedContentErrorPolicy::HardError);
361        assert!(result.is_err());
362
363        // `Inline`: the note still renders, carrying an inline error box.
364        let result = MarkupLanguage::Markdown
365            .render(input, EmbeddedContentErrorPolicy::Inline)
366            .unwrap();
367        assert!(result.contains("math-error"));
368
369        // An inline `$…$` formula that fails renders an inline `<span>` (not a
370        // block box) so it does not break the surrounding paragraph.
371        let inline = MarkupLanguage::Markdown
372            .render("text $\\left( x$ more", EmbeddedContentErrorPolicy::Inline)
373            .unwrap();
374        assert!(inline.contains("math-error-inline"));
375    }
376
377    #[cfg(feature = "latex")]
378    #[test]
379    fn test_latex_embedded_marker() {
380        // `\frac{1}` is missing its second argument; latex2mathml does not
381        // return `Err` but `Ok` with an embedded `[PARSE ERROR: …]` marker.
382        let input = "```math\n\\frac{1}\n```";
383
384        // `HardError`: the embedded marker is detected and aborts rendering.
385        let result = MarkupLanguage::Markdown.render(input, EmbeddedContentErrorPolicy::HardError);
386        assert!(result.is_err());
387
388        // `Inline`: the marker-annotated MathML is kept so the fault stays
389        // visible in place, and the marker is tagged for red CSS highlighting.
390        let result = MarkupLanguage::Markdown
391            .render(input, EmbeddedContentErrorPolicy::Inline)
392            .unwrap();
393        assert!(result.contains("[PARSE ERROR"));
394        assert!(result.contains("<math"));
395        assert!(result.contains("<mtext class=\"math-parse-error\">"));
396    }
397
398    #[test]
399    fn test_input_converter_md() {
400        let ic = InputConverter::build("md");
401        let input: &str =
402            "<div id=\"videopodcast\">outside <span id=\"pills\">inside</span>\n</div>";
403        let expected: &str = "outside inside";
404
405        let result = ic(input.to_string());
406        assert_eq!(result.unwrap(), expected);
407
408        //
409        let input: &str = r#"<p><a href="/my_uri">link</a></p>"#;
410        let expected: &str = "[link](/my_uri)";
411
412        let result = ic(input.to_string());
413        assert_eq!(result.unwrap(), expected);
414
415        //
416        // [CommonMark: Example 489](https://spec.commonmark.org/0.31.2/#example-489)
417        let input: &str = r#"<p><a href="/my uri">link</a></p>"#;
418        let expected: &str = "[link](</my uri>)";
419
420        let result = ic(input.to_string());
421        assert_eq!(result.unwrap(), expected);
422
423        //
424        // [CommonMark: Example 489](https://spec.commonmark.org/0.31.2/#example-489)
425        let input: &str = r#"<p><a href="/my%20uri">link</a></p>"#;
426        let expected: &str = "[link](</my uri>)";
427
428        let result = ic(input.to_string());
429        assert_eq!(result.unwrap(), expected);
430
431        //
432        // We want ATX style headers.
433        let input: &str = r#"<p><h1>Title</h1></p>"#;
434        let expected: &str = "# Title";
435
436        let result = ic(input.to_string());
437        assert_eq!(result.unwrap(), expected);
438    }
439
440    #[test]
441    fn test_filter_tags() {
442        let input: &str =
443            "A<div id=\"videopodcast\">out<p>side <span id=\"pills\">inside</span>\n</div>B";
444        let expected: &str = "Aout<p>side inside\nB";
445
446        let result = InputConverter::filter_tags(input.to_string());
447        assert_eq!(result, expected);
448
449        let input: &str = "A<B<C <div>D<E<p>F<>G";
450        let expected: &str = "A<B<C D<E<p>F<>G";
451
452        let result = InputConverter::filter_tags(input.to_string());
453        assert_eq!(result, expected);
454    }
455}
456// `rewrite_rel_links=true`