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 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(String),
31 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 AssetUrl::External(path) => write!(f, "{}", path.display()),
41 }
42 }
43}
44
45impl HtmlToAstParser {
46 pub fn convert_source(&mut self, element: &HtmlElement) -> Node {
48 if element.children.len() != 1 {
49 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 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 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 pub fn convert_frame(&mut self, frame: &Frame) -> Node {
125 if self.feat.remove_html {
126 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 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 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 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 Ok(Self::base64_url(svg))
197 }
198
199 fn base64_url(data: &str) -> AssetUrl {
201 AssetUrl::Embedded(base64::engine::general_purpose::STANDARD.encode(data.as_bytes()))
202 }
203 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 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 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 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 _ => eco_format!("{}{PRELUDE}#[{}]", import_prefix, src),
277 }),
278 )
279 .unwrap();
280
281 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 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}