typlite/parser/
media.rs

1//! Media processing module, handles images, SVG and Frame media elements
2
3use core::fmt;
4use std::path::PathBuf;
5use std::sync::{Arc, LazyLock};
6
7use base64::Engine;
8use cmark_writer::ast::{HtmlAttribute, HtmlElement as CmarkHtmlElement, Node};
9use ecow::{EcoString, eco_format};
10use tinymist_project::diag::print_diagnostics_to_string;
11use tinymist_project::{EntryReader, MEMORY_MAIN_ENTRY, TaskInputs, base::ShadowApi};
12use typst::{
13    World,
14    foundations::{Bytes, Dict, IntoValue},
15    html::{HtmlElement, HtmlNode},
16    layout::{Abs, Frame},
17    utils::LazyHash,
18};
19
20use crate::{
21    ColorTheme,
22    attributes::{IdocAttr, TypliteAttrsParser, md_attr},
23    common::ExternalFrameNode,
24};
25
26use super::core::HtmlToAstParser;
27
28enum AssetUrl {
29    /// Embedded Base64 SVG data
30    Embedded(String),
31    /// External file path
32    External(PathBuf),
33}
34
35impl fmt::Display for AssetUrl {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            AssetUrl::Embedded(data) => write!(f, "data:image/svg+xml;base64,{data}"),
39            // todo: correct relative path?
40            AssetUrl::External(path) => write!(f, "{}", path.display()),
41        }
42    }
43}
44
45impl HtmlToAstParser {
46    /// Convert Typst source to CommonMark node
47    pub fn convert_source(&mut self, element: &HtmlElement) -> Node {
48        if element.children.len() != 1 {
49            // Construct error node
50            return Node::HtmlElement(CmarkHtmlElement {
51                tag: EcoString::inline("div"),
52                attributes: vec![HtmlAttribute {
53                    name: EcoString::inline("class"),
54                    value: EcoString::inline("error"),
55                }],
56                children: vec![Node::Text(eco_format!(
57                    "source contains not only one child: {}, whose attrs: {:?}",
58                    element.children.len(),
59                    element.attrs
60                ))],
61                self_closing: false,
62            });
63        }
64
65        let Some(HtmlNode::Frame(frame)) = element.children.first() else {
66            // todo: utils to remove duplicated error construction
67            return Node::HtmlElement(CmarkHtmlElement {
68                tag: EcoString::inline("div"),
69                attributes: vec![HtmlAttribute {
70                    name: EcoString::inline("class"),
71                    value: EcoString::inline("error"),
72                }],
73                children: vec![Node::Text(eco_format!(
74                    "source contains not a frame, but: {:?}",
75                    element.children
76                ))],
77                self_closing: false,
78            });
79        };
80
81        let svg = typst_svg::svg_frame(frame);
82        let frame_url = match self.create_asset_url(&svg) {
83            Ok(url) => url,
84            Err(e) => {
85                // Construct error node
86                return Node::HtmlElement(CmarkHtmlElement {
87                    tag: EcoString::inline("div"),
88                    attributes: vec![HtmlAttribute {
89                        name: EcoString::inline("class"),
90                        value: EcoString::inline("error"),
91                    }],
92                    children: vec![Node::Text(eco_format!("Error creating source URL: {e}"))],
93                    self_closing: false,
94                });
95            }
96        };
97
98        let media = element.attrs.0.iter().find_map(|(name, data)| {
99            if *name == md_attr::media {
100                Some(data.clone())
101            } else {
102                None
103            }
104        });
105
106        Node::HtmlElement(CmarkHtmlElement {
107            tag: EcoString::inline("source"),
108            attributes: vec![
109                HtmlAttribute {
110                    name: EcoString::inline("media"),
111                    value: media.unwrap_or_else(|| "all".into()),
112                },
113                HtmlAttribute {
114                    name: EcoString::inline("srcset"),
115                    value: frame_url.to_string().into(),
116                },
117            ],
118            children: vec![],
119            self_closing: true,
120        })
121    }
122
123    /// Convert Typst frame to CommonMark node
124    pub fn convert_frame(&mut self, frame: &Frame) -> Node {
125        if self.feat.remove_html {
126            // todo: make error silent is not good.
127            return Node::Text(EcoString::new());
128        }
129
130        let svg = typst_svg::svg_frame(frame);
131        self.convert_svg(svg)
132    }
133
134    fn convert_svg(&mut self, svg: String) -> Node {
135        let frame_url = self.create_asset_url(&svg);
136
137        match frame_url {
138            Ok(url @ AssetUrl::Embedded(..)) => Self::create_embedded_frame(&url),
139            Ok(AssetUrl::External(file_path)) => Node::Custom(Box::new(ExternalFrameNode {
140                file_path,
141                alt_text: EcoString::inline("typst-frame"),
142                svg,
143            })),
144            Err(e) => {
145                if self.feat.soft_error {
146                    let b64_data = Self::base64_url(&svg);
147                    Self::create_embedded_frame(&b64_data)
148                } else {
149                    // Construct error node
150                    Node::HtmlElement(CmarkHtmlElement {
151                        tag: EcoString::inline("div"),
152                        attributes: vec![HtmlAttribute {
153                            name: EcoString::inline("class"),
154                            value: EcoString::inline("error"),
155                        }],
156                        children: vec![Node::Text(eco_format!("Error creating frame URL: {e}"))],
157                        self_closing: false,
158                    })
159                }
160            }
161        }
162    }
163
164    /// Create embedded frame node
165    fn create_embedded_frame(url: &AssetUrl) -> Node {
166        Node::HtmlElement(CmarkHtmlElement {
167            tag: EcoString::inline("img"),
168            attributes: vec![
169                HtmlAttribute {
170                    name: EcoString::inline("alt"),
171                    value: EcoString::inline("typst-block"),
172                },
173                HtmlAttribute {
174                    name: EcoString::inline("src"),
175                    value: url.to_string().into(),
176                },
177            ],
178            children: vec![],
179            self_closing: true,
180        })
181    }
182
183    /// Convert asset to asset url
184    fn create_asset_url(&mut self, svg: &str) -> crate::Result<AssetUrl> {
185        if let Some(assets_path) = &self.feat.assets_path {
186            let file_id = self.asset_counter;
187            self.asset_counter += 1;
188            let file_name = format!("frame_{file_id}.svg");
189            let file_path = assets_path.join(&file_name);
190
191            std::fs::write(&file_path, svg.as_bytes())?;
192            return Ok(AssetUrl::External(file_path));
193        }
194
195        // Fall back to embedded mode if no external asset path is specified
196        Ok(Self::base64_url(svg))
197    }
198
199    /// Create embedded frame node
200    fn base64_url(data: &str) -> AssetUrl {
201        AssetUrl::Embedded(base64::engine::general_purpose::STANDARD.encode(data.as_bytes()))
202    }
203    /// Convert Typst inline document to CommonMark node
204    pub fn convert_idoc(&mut self, element: &HtmlElement) -> Node {
205        static DARK_THEME_INPUT: LazyLock<Arc<LazyHash<Dict>>> = LazyLock::new(|| {
206            Arc::new(LazyHash::new(Dict::from_iter(std::iter::once((
207                "x-color-theme".into(),
208                "dark".into_value(),
209            )))))
210        });
211
212        if self.feat.remove_html {
213            eprintln!("Removing idoc element due to remove_html feature");
214            // todo: make error silent is not good.
215            return Node::Text(EcoString::new());
216        }
217        let attrs = match IdocAttr::parse(&element.attrs) {
218            Ok(attrs) => attrs,
219            Err(e) => {
220                if self.feat.soft_error {
221                    return Node::Text(eco_format!("Error parsing idoc attributes: {e}"));
222                } else {
223                    // Construct error node
224                    return Node::HtmlElement(CmarkHtmlElement {
225                        tag: EcoString::inline("div"),
226                        attributes: vec![HtmlAttribute {
227                            name: EcoString::inline("class"),
228                            value: EcoString::inline("error"),
229                        }],
230                        children: vec![Node::Text(eco_format!(
231                            "Error parsing idoc attributes: {e}"
232                        ))],
233                        self_closing: false,
234                    });
235                }
236            }
237        };
238
239        let src = attrs.src;
240        let mode = attrs.mode;
241
242        let mut world = self.world.clone().task(TaskInputs {
243            entry: Some(
244                self.world
245                    .entry_state()
246                    .select_in_workspace(MEMORY_MAIN_ENTRY.vpath().as_rooted_path()),
247            ),
248            inputs: match self.feat.color_theme {
249                Some(ColorTheme::Dark) => Some(DARK_THEME_INPUT.clone()),
250                None | Some(ColorTheme::Light) => None,
251            },
252        });
253        // todo: cost some performance.
254        world.take_db();
255
256        let main = world.main();
257
258        const PRELUDE: &str = r##"#set page(width: auto, height: auto, margin: (y: 0.45em, rest: 0em), fill: none);
259            #set text(fill: rgb("#c0caf5")) if sys.inputs.at("x-color-theme", default: none) == "dark";"##;
260
261        let import_prefix = if let Some(ref import_ctx) = self.feat.import_context {
262            format!("{import_ctx}\n")
263        } else {
264            String::new()
265        };
266
267        world
268            .map_shadow_by_id(
269                main,
270                Bytes::from_string(match mode.as_str() {
271                    "code" => eco_format!("{}{PRELUDE}#{{{src}}}", import_prefix),
272                    "math" => eco_format!("{}{PRELUDE}${src}$", import_prefix),
273                    "markup" => eco_format!("{}{PRELUDE}#[{}]", import_prefix, src),
274                    // todo check mode
275                    //  "markup" |
276                    _ => eco_format!("{}{PRELUDE}#[{}]", import_prefix, src),
277                }),
278            )
279            .unwrap();
280
281        //todo: ignoring warnings
282        let doc = typst::compile(&world);
283        let doc = match doc.output {
284            Ok(doc) => doc,
285            Err(e) => {
286                let diag = doc.warnings.iter().chain(e.iter());
287
288                let e = print_diagnostics_to_string(
289                    &world,
290                    diag,
291                    tinymist_project::DiagnosticFormat::Human,
292                )
293                .unwrap_or_else(|e| e);
294
295                if self.feat.soft_error {
296                    return Node::Text(eco_format!("Error compiling idoc: {e}"));
297                } else {
298                    // Construct error node
299                    return Node::HtmlElement(CmarkHtmlElement {
300                        tag: EcoString::inline("div"),
301                        attributes: vec![HtmlAttribute {
302                            name: EcoString::inline("class"),
303                            value: EcoString::inline("error"),
304                        }],
305                        children: vec![Node::Text(eco_format!("Error compiling idoc: {e}"))],
306                        self_closing: false,
307                    });
308                }
309            }
310        };
311
312        let svg = typst_svg::svg_merged(&doc, Abs::zero());
313        self.convert_svg(svg)
314    }
315}