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