1use 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(String),
32 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 AssetUrl::External(path) => write!(f, "{}", path.display()),
42 }
43 }
44}
45
46impl HtmlToAstParser {
47 pub fn convert_source(&mut self, element: &HtmlElement) -> Node {
49 if element.children.len() != 1 {
50 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 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 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 pub fn convert_frame(&mut self, frame: &Frame) -> Node {
126 if self.feat.remove_html {
127 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 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 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 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 Ok(Self::base64_url(svg))
198 }
199
200 fn base64_url(data: &str) -> AssetUrl {
202 AssetUrl::Embedded(base64::engine::general_purpose::STANDARD.encode(data.as_bytes()))
203 }
204 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 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 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 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 _ => 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 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}