1use std::str::FromStr;
2use std::sync::Arc;
3
4use comemo::Track;
5use ecow::EcoString;
6use tinymist_std::error::prelude::*;
7use tinymist_std::typst::{TypstDocument, TypstHtmlDocument, TypstPagedDocument};
8use tinymist_world::{CompileSnapshot, CompilerFeat, ExportComputation, WorldComputeGraph};
9use typst::diag::{SourceResult, StrResult};
10use typst::foundations::{Bytes, Content, IntoValue, LocatableSelector, Scope, Value};
11use typst::layout::Abs;
12use typst::routines::EvalMode;
13use typst::syntax::{ast, Span, SyntaxNode};
14use typst::visualize::Color;
15use typst::World;
16use typst_eval::eval_string;
17
18use crate::model::{ExportHtmlTask, ExportPngTask, ExportSvgTask};
19use crate::primitives::TaskWhen;
20use crate::{ExportTransform, Pages, QueryTask};
21
22#[cfg(feature = "pdf")]
23pub mod pdf;
24#[cfg(feature = "pdf")]
25pub use pdf::*;
26#[cfg(feature = "text")]
27pub mod text;
28#[cfg(feature = "text")]
29pub use text::*;
30
31pub struct SvgFlag;
32pub struct PngFlag;
33pub struct HtmlFlag;
34
35pub struct ExportTimings;
36
37impl ExportTimings {
38 pub fn needs_run<F: CompilerFeat, D: typst::Document>(
39 snap: &CompileSnapshot<F>,
40 timing: Option<TaskWhen>,
41 docs: Option<&D>,
42 ) -> Option<bool> {
43 snap.signal
44 .should_run_task(timing.unwrap_or_default(), docs)
45 }
46}
47
48pub struct SvgExport;
49
50impl<F: CompilerFeat> ExportComputation<F, TypstPagedDocument> for SvgExport {
51 type Output = String;
52 type Config = ExportSvgTask;
53
54 fn run(
55 _graph: &Arc<WorldComputeGraph<F>>,
56 doc: &Arc<TypstPagedDocument>,
57 config: &ExportSvgTask,
58 ) -> Result<String> {
59 let (is_first, merged_gap) = get_page_selection(&config.export)?;
60
61 let first_page = doc.pages.first();
62
63 Ok(if is_first {
64 if let Some(first_page) = first_page {
65 typst_svg::svg(first_page)
66 } else {
67 typst_svg::svg_merged(doc, merged_gap)
68 }
69 } else {
70 typst_svg::svg_merged(doc, merged_gap)
71 })
72 }
73}
74
75pub struct PngExport;
84
85impl<F: CompilerFeat> ExportComputation<F, TypstPagedDocument> for PngExport {
86 type Output = Bytes;
87 type Config = ExportPngTask;
88
89 fn run(
90 _graph: &Arc<WorldComputeGraph<F>>,
91 doc: &Arc<TypstPagedDocument>,
92 config: &ExportPngTask,
93 ) -> Result<Bytes> {
94 let ppi = config.ppi.to_f32();
95 if ppi <= 1e-6 {
96 tinymist_std::bail!("invalid ppi: {ppi}");
97 }
98
99 let fill = if let Some(fill) = &config.fill {
100 parse_color(fill.clone()).map_err(|err| anyhow::anyhow!("invalid fill ({err})"))?
101 } else {
102 Color::WHITE
103 };
104
105 let (is_first, merged_gap) = get_page_selection(&config.export)?;
106
107 let ppp = ppi / 72.;
108 let pixmap = if is_first {
109 if let Some(first_page) = doc.pages.first() {
110 typst_render::render(first_page, ppp)
111 } else {
112 typst_render::render_merged(doc, ppp, merged_gap, Some(fill))
113 }
114 } else {
115 typst_render::render_merged(doc, ppp, merged_gap, Some(fill))
116 };
117
118 pixmap
119 .encode_png()
120 .map(Bytes::new)
121 .context_ut("failed to encode PNG")
122 }
123}
124
125pub struct HtmlExport;
134
135impl<F: CompilerFeat> ExportComputation<F, TypstHtmlDocument> for HtmlExport {
136 type Output = String;
137 type Config = ExportHtmlTask;
138
139 fn run(
140 _graph: &Arc<WorldComputeGraph<F>>,
141 doc: &Arc<TypstHtmlDocument>,
142 _config: &ExportHtmlTask,
143 ) -> Result<String> {
144 Ok(typst_html::html(doc)?)
145 }
146}
147
148pub struct DocumentQuery;
157
158impl DocumentQuery {
159 pub fn retrieve<D: typst::Document>(
162 world: &dyn World,
163 selector: &str,
164 document: &D,
165 ) -> StrResult<Vec<Content>> {
166 let selector = eval_string(
167 &typst::ROUTINES,
168 world.track(),
169 selector,
170 Span::detached(),
171 EvalMode::Code,
172 Scope::default(),
173 )
174 .map_err(|errors| {
175 let mut message = EcoString::from("failed to evaluate selector");
176 for (i, error) in errors.into_iter().enumerate() {
177 message.push_str(if i == 0 { ": " } else { ", " });
178 message.push_str(&error.message);
179 }
180 message
181 })?
182 .cast::<LocatableSelector>()
183 .map_err(|e| EcoString::from(format!("failed to cast: {}", e.message())))?;
184
185 Ok(document
186 .introspector()
187 .query(&selector.0)
188 .into_iter()
189 .collect::<Vec<_>>())
190 }
191
192 fn run_inner<F: CompilerFeat, D: typst::Document>(
193 g: &Arc<WorldComputeGraph<F>>,
194 doc: &Arc<D>,
195 config: &QueryTask,
196 ) -> Result<Vec<Value>> {
197 let selector = &config.selector;
198 let elements = Self::retrieve(&g.snap.world, selector, doc.as_ref())
199 .map_err(|e| anyhow::anyhow!("failed to retrieve: {e}"))?;
200 if config.one && elements.len() != 1 {
201 bail!("expected exactly one element, found {}", elements.len());
202 }
203
204 Ok(elements
205 .into_iter()
206 .filter_map(|c| match &config.field {
207 Some(field) => c.get_by_name(field).ok(),
208 _ => Some(c.into_value()),
209 })
210 .collect())
211 }
212
213 pub fn doc_get_as_value<F: CompilerFeat>(
214 g: &Arc<WorldComputeGraph<F>>,
215 doc: &TypstDocument,
216 config: &QueryTask,
217 ) -> Result<serde_json::Value> {
218 match doc {
219 TypstDocument::Paged(doc) => Self::get_as_value(g, doc, config),
220 TypstDocument::Html(doc) => Self::get_as_value(g, doc, config),
221 }
222 }
223
224 pub fn get_as_value<F: CompilerFeat, D: typst::Document>(
225 g: &Arc<WorldComputeGraph<F>>,
226 doc: &Arc<D>,
227 config: &QueryTask,
228 ) -> Result<serde_json::Value> {
229 let mapped = Self::run_inner(g, doc, config)?;
230
231 let res = if config.one {
232 let Some(value) = mapped.first() else {
233 bail!("no such field found for element");
234 };
235 serde_json::to_value(value)
236 } else {
237 serde_json::to_value(&mapped)
238 };
239
240 res.context("failed to serialize")
241 }
242}
243
244impl<F: CompilerFeat, D: typst::Document> ExportComputation<F, D> for DocumentQuery {
245 type Output = SourceResult<String>;
246 type Config = QueryTask;
247
248 fn run(
249 g: &Arc<WorldComputeGraph<F>>,
250 doc: &Arc<D>,
251 config: &QueryTask,
252 ) -> Result<SourceResult<String>> {
253 let pretty = false;
254 let mapped = Self::run_inner(g, doc, config)?;
255
256 let res = if config.one {
257 let Some(value) = mapped.first() else {
258 bail!("no such field found for element");
259 };
260 serialize(value, &config.format, pretty)
261 } else {
262 serialize(&mapped, &config.format, pretty)
263 };
264
265 res.map(Ok)
266 }
267}
268
269fn serialize(data: &impl serde::Serialize, format: &str, pretty: bool) -> Result<String> {
271 Ok(match format {
272 "json" if pretty => serde_json::to_string_pretty(data).context("serialize query")?,
273 "json" => serde_json::to_string(data).context("serialize query")?,
274 "yaml" => serde_yaml::to_string(&data).context_ut("serialize query")?,
275 "txt" => {
276 use serde_json::Value::*;
277 let value = serde_json::to_value(data).context("serialize query")?;
278 match value {
279 String(s) => s,
280 _ => {
281 let kind = match value {
282 Null => "null",
283 Bool(_) => "boolean",
284 Number(_) => "number",
285 String(_) => "string",
286 Array(_) => "array",
287 Object(_) => "object",
288 };
289 bail!("expected a string value for format: {format}, got {kind}")
290 }
291 }
292 }
293 _ => bail!("unsupported format for query: {format}"),
294 })
295}
296
297pub fn get_page_selection(task: &crate::ExportTask) -> Result<(bool, Abs)> {
299 let is_first = task
300 .transform
301 .iter()
302 .any(|t| matches!(t, ExportTransform::Pages { ranges, .. } if ranges == &[Pages::FIRST]));
303
304 let mut gap_res = Abs::default();
305 if !is_first {
306 for trans in &task.transform {
307 if let ExportTransform::Merge { gap } = trans {
308 let gap = gap
309 .as_deref()
310 .map(parse_length)
311 .transpose()
312 .context_ut("failed to parse gap")?;
313 gap_res = gap.unwrap_or_default();
314 }
315 }
316 }
317
318 Ok((is_first, gap_res))
319}
320
321fn parse_length(gap: &str) -> Result<Abs> {
322 let length = typst::syntax::parse_code(gap);
323 if length.erroneous() {
324 bail!("invalid length: {gap}, errors: {:?}", length.errors());
325 }
326
327 let length: Option<ast::Numeric> = descendants(&length).into_iter().find_map(SyntaxNode::cast);
328
329 let Some(length) = length else {
330 bail!("not a length: {gap}");
331 };
332
333 let (value, unit) = length.get();
334 match unit {
335 ast::Unit::Pt => Ok(Abs::pt(value)),
336 ast::Unit::Mm => Ok(Abs::mm(value)),
337 ast::Unit::Cm => Ok(Abs::cm(value)),
338 ast::Unit::In => Ok(Abs::inches(value)),
339 _ => bail!("invalid unit: {unit:?} in {gap}"),
340 }
341}
342
343fn descendants(node: &SyntaxNode) -> impl IntoIterator<Item = &SyntaxNode> + '_ {
345 let mut res = vec![];
346 for child in node.children() {
347 res.push(child);
348 res.extend(descendants(child));
349 }
350
351 res
352}
353
354fn parse_color(fill: String) -> anyhow::Result<Color> {
355 match fill.as_str() {
356 "black" => Ok(Color::BLACK),
357 "white" => Ok(Color::WHITE),
358 "red" => Ok(Color::RED),
359 "green" => Ok(Color::GREEN),
360 "blue" => Ok(Color::BLUE),
361 hex if hex.starts_with('#') => {
362 Color::from_str(&hex[1..]).map_err(|e| anyhow::anyhow!("failed to parse color: {e}"))
363 }
364 _ => anyhow::bail!("invalid color: {fill}"),
365 }
366}
367
368#[cfg(test)]
369mod tests {
370
371 use super::*;
372
373 #[test]
374 fn test_parse_color() {
375 assert_eq!(parse_color("black".to_owned()).unwrap(), Color::BLACK);
376 assert_eq!(parse_color("white".to_owned()).unwrap(), Color::WHITE);
377 assert_eq!(parse_color("red".to_owned()).unwrap(), Color::RED);
378 assert_eq!(parse_color("green".to_owned()).unwrap(), Color::GREEN);
379 assert_eq!(parse_color("blue".to_owned()).unwrap(), Color::BLUE);
380 assert_eq!(
381 parse_color("#000000".to_owned()).unwrap().to_hex(),
382 "#000000"
383 );
384 assert_eq!(
385 parse_color("#ffffff".to_owned()).unwrap().to_hex(),
386 "#ffffff"
387 );
388 assert_eq!(
389 parse_color("#000000cc".to_owned()).unwrap().to_hex(),
390 "#000000cc"
391 );
392 assert!(parse_color("invalid".to_owned()).is_err());
393 }
394
395 #[test]
396 fn test_parse_length() {
397 assert_eq!(parse_length("1pt").unwrap(), Abs::pt(1.));
398 assert_eq!(parse_length("1mm").unwrap(), Abs::mm(1.));
399 assert_eq!(parse_length("1cm").unwrap(), Abs::cm(1.));
400 assert_eq!(parse_length("1in").unwrap(), Abs::inches(1.));
401 assert!(parse_length("1").is_err());
402 assert!(parse_length("1px").is_err());
403 }
404}