1use std::collections::BTreeMap;
4use std::iter::Peekable;
5
6use typst_syntax::ast::{self, AstNode};
7use typst_syntax::{SyntaxNode, parse};
8
9use crate::backend::{
10 MarkupBackend, MarkupDecodeOptions, MarkupEncodeOptions, MarkupError, MarkupFidelity,
11 MarkupLoss,
12};
13use crate::markup::{
14 BackendId, Inline, MarkupBlock, MarkupDoc, MathSource, SourceDoc, Span, SpanState,
15};
16
17#[path = "typst_writer.rs"]
18mod typst_writer;
19
20use typst_writer::TypstEncoder;
21
22#[derive(Clone, Debug, Default)]
24pub struct TypstBackend;
25
26impl MarkupBackend for TypstBackend {
27 fn id(&self) -> BackendId {
28 typst_id()
29 }
30
31 fn decode(
32 &self,
33 input: &str,
34 opts: &MarkupDecodeOptions,
35 ) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
36 let root = parse(input);
37 let (errors, warnings) = root.errors_and_warnings();
38 if !errors.is_empty() {
39 return Err(MarkupError::Decode(format!(
40 "typst syntax contains {} error(s)",
41 errors.len()
42 )));
43 }
44
45 let markup = root
46 .cast::<ast::Markup>()
47 .ok_or_else(|| MarkupError::Decode("typst root is not markup".to_owned()))?;
48 let mut parser = TypstParser::new(input, opts);
49 parser.fidelity.warnings.extend(
50 warnings
51 .into_iter()
52 .map(|_| "typst syntax warning".to_owned()),
53 );
54 let blocks = parser.parse_markup(markup);
55 let title = blocks.iter().find_map(|block| match block {
56 MarkupBlock::Heading { level: 1, text, .. } => Some(inline_plain_text(text)),
57 _ => None,
58 });
59 let source = opts.preserve_source.then(|| SourceDoc {
60 backend: typst_id(),
61 text: input.to_owned(),
62 });
63 Ok((
64 MarkupDoc {
65 title,
66 blocks,
67 attrs: BTreeMap::new(),
68 source,
69 },
70 parser.fidelity,
71 ))
72 }
73
74 fn encode(
75 &self,
76 doc: &MarkupDoc,
77 opts: &MarkupEncodeOptions,
78 ) -> Result<(String, MarkupFidelity), MarkupError> {
79 let mut encoder = TypstEncoder::new(opts);
80 let source = encoder.write_doc(doc);
81 if opts.fail_on_loss && !encoder.fidelity().dropped.is_empty() {
82 return Err(MarkupError::Encode(format!(
83 "typst encode dropped {} unsupported fragment(s)",
84 encoder.fidelity().dropped.len()
85 )));
86 }
87 Ok((source, encoder.into_fidelity()))
88 }
89}
90
91struct TypstParser<'a> {
92 input: &'a str,
93 cursor: usize,
94 preserve_raw: bool,
95 fidelity: MarkupFidelity,
96}
97
98impl<'a> TypstParser<'a> {
99 fn new(input: &'a str, opts: &MarkupDecodeOptions) -> Self {
100 Self {
101 input,
102 cursor: 0,
103 preserve_raw: opts.preserve_raw,
104 fidelity: MarkupFidelity::exact(typst_id()),
105 }
106 }
107
108 fn parse_markup(&mut self, markup: ast::Markup<'_>) -> Vec<MarkupBlock> {
109 let mut blocks = Vec::new();
110 let mut paragraph = PendingParagraph::default();
111 let mut list: Option<PendingList> = None;
112 for expr in markup.exprs() {
113 let span = self.span_for(expr.to_untyped());
114 match expr {
115 ast::Expr::Parbreak(_) => {
116 self.flush_paragraph(&mut blocks, &mut paragraph);
117 self.flush_list(&mut blocks, &mut list);
118 }
119 ast::Expr::Heading(heading) => {
120 self.flush_paragraph(&mut blocks, &mut paragraph);
121 self.flush_list(&mut blocks, &mut list);
122 blocks.push(MarkupBlock::Heading {
123 level: heading.depth().get().min(u8::MAX as usize) as u8,
124 text: self.inlines_from_markup(heading.body()),
125 id: None,
126 span,
127 });
128 }
129 ast::Expr::Raw(raw) if raw.block() => {
130 self.flush_paragraph(&mut blocks, &mut paragraph);
131 self.flush_list(&mut blocks, &mut list);
132 blocks.push(MarkupBlock::CodeBlock {
133 lang: raw.lang().map(|lang| lang.get().to_string()),
134 code: raw_text(raw),
135 span,
136 });
137 }
138 ast::Expr::Equation(equation) if equation.block() => {
139 self.flush_paragraph(&mut blocks, &mut paragraph);
140 self.flush_list(&mut blocks, &mut list);
141 blocks.push(MarkupBlock::MathBlock {
142 source: typst_math(equation.body()),
143 span,
144 });
145 }
146 ast::Expr::ListItem(item) => {
147 self.flush_paragraph(&mut blocks, &mut paragraph);
148 self.push_list_item(&mut blocks, &mut list, false, item.body(), span);
149 }
150 ast::Expr::EnumItem(item) => {
151 self.flush_paragraph(&mut blocks, &mut paragraph);
152 self.push_list_item(&mut blocks, &mut list, true, item.body(), span);
153 }
154 ast::Expr::FuncCall(call) if self.call_name(call.callee()) == Some("table") => {
155 self.flush_paragraph(&mut blocks, &mut paragraph);
156 self.flush_list(&mut blocks, &mut list);
157 if let Some(table) = self.table_from_call(call, span.clone()) {
158 blocks.push(table);
159 } else if let Some(raw) =
160 self.raw_block(expr_source(expr), "table", span, "unsupported typst table")
161 {
162 blocks.push(raw);
163 }
164 }
165 ast::Expr::FuncCall(call) if self.call_name(call.callee()) == Some("figure") => {
166 self.flush_paragraph(&mut blocks, &mut paragraph);
167 self.flush_list(&mut blocks, &mut list);
168 if let Some(figure) = self.figure_from_call(call, span.clone()) {
169 blocks.push(figure);
170 } else if let Some(raw) = self.raw_block(
171 expr_source(expr),
172 "figure",
173 span,
174 "unsupported typst figure",
175 ) {
176 blocks.push(raw);
177 }
178 }
179 ast::Expr::FuncCall(call) if self.call_name(call.callee()) != Some("link") => {
180 self.flush_paragraph(&mut blocks, &mut paragraph);
181 self.flush_list(&mut blocks, &mut list);
182 let path = self
183 .call_name(call.callee())
184 .unwrap_or("function")
185 .to_owned();
186 if let Some(raw) = self.raw_block(
187 call.to_untyped().full_text().to_string(),
188 &path,
189 span,
190 "unsupported typst function is preserved but not executed",
191 ) {
192 blocks.push(raw);
193 }
194 }
195 ast::Expr::ModuleInclude(_) => {
196 self.flush_paragraph(&mut blocks, &mut paragraph);
197 self.flush_list(&mut blocks, &mut list);
198 if let Some(raw) = self.raw_block(
199 expr_source(expr),
200 "include",
201 span,
202 "typst include is preserved but not executed",
203 ) {
204 blocks.push(raw);
205 }
206 }
207 ast::Expr::ModuleImport(_) => {
208 self.flush_paragraph(&mut blocks, &mut paragraph);
209 self.flush_list(&mut blocks, &mut list);
210 if let Some(raw) = self.raw_block(
211 expr_source(expr),
212 "import",
213 span,
214 "typst import is preserved but not executed",
215 ) {
216 blocks.push(raw);
217 }
218 }
219 other => {
220 self.flush_list(&mut blocks, &mut list);
221 if let Some(inline) = self.inline_from_expr(other, "inline") {
222 paragraph.push(inline, span);
223 }
224 }
225 }
226 }
227 self.flush_paragraph(&mut blocks, &mut paragraph);
228 self.flush_list(&mut blocks, &mut list);
229 blocks
230 }
231
232 fn push_list_item(
233 &mut self,
234 blocks: &mut Vec<MarkupBlock>,
235 list: &mut Option<PendingList>,
236 ordered: bool,
237 body: ast::Markup<'_>,
238 span: Option<Span>,
239 ) {
240 if list.as_ref().is_some_and(|list| list.ordered != ordered) {
241 self.flush_list(blocks, list);
242 }
243 let item = self.blocks_from_item_body(body);
244 list.get_or_insert_with(|| PendingList::new(ordered))
245 .push(item, span);
246 }
247
248 fn blocks_from_item_body<'b>(&mut self, body: ast::Markup<'b>) -> Vec<MarkupBlock> {
249 let content = self.inlines_from_markup(body);
250 if content.is_empty() {
251 Vec::new()
252 } else {
253 vec![MarkupBlock::Paragraph {
254 content,
255 span: None,
256 }]
257 }
258 }
259
260 fn inlines_from_markup<'b>(&mut self, markup: ast::Markup<'b>) -> Vec<Inline> {
261 let mut items = Vec::new();
262 let mut exprs = markup.exprs().peekable();
263 while let Some(expr) = exprs.next() {
264 if let Some(link) = self.link_from_call_with_body(expr, &mut exprs) {
265 items.push(link);
266 continue;
267 }
268 if let Some(inline) = self.inline_from_expr(expr, "inline") {
269 items.push(inline);
270 }
271 }
272 items
273 }
274
275 fn inline_from_expr<'b>(&mut self, expr: ast::Expr<'b>, path: &str) -> Option<Inline> {
276 match expr {
277 ast::Expr::Text(text) => Some(Inline::Text(text.get().to_string())),
278 ast::Expr::Space(_) => Some(Inline::Text(" ".to_owned())),
279 ast::Expr::Linebreak(_) => Some(Inline::Text("\n".to_owned())),
280 ast::Expr::Escape(escape) => Some(Inline::Text(escape.get().to_string())),
281 ast::Expr::Shorthand(shorthand) => Some(Inline::Text(shorthand.get().to_string())),
282 ast::Expr::SmartQuote(quote) => Some(Inline::Text(
283 if quote.double() { "\"" } else { "'" }.to_owned(),
284 )),
285 ast::Expr::Strong(strong) => {
286 Some(Inline::Strong(self.inlines_from_markup(strong.body())))
287 }
288 ast::Expr::Emph(emph) => Some(Inline::Emph(self.inlines_from_markup(emph.body()))),
289 ast::Expr::Raw(raw) if !raw.block() => Some(Inline::Code(raw_text(raw))),
290 ast::Expr::Link(link) => {
291 let target = link.get().to_string();
292 Some(Inline::Link {
293 label: vec![Inline::Text(target.clone())],
294 target,
295 })
296 }
297 ast::Expr::Equation(equation) if !equation.block() => {
298 Some(Inline::Math(typst_math(equation.body())))
299 }
300 ast::Expr::FuncCall(call) if self.call_name(call.callee()) == Some("link") => {
301 let target = self.first_string_arg(call)?;
302 Some(Inline::Link {
303 label: vec![Inline::Text(target.clone())],
304 target,
305 })
306 }
307 ast::Expr::Parbreak(_) => None,
308 other => self.raw_inline(expr_source(other), path, "unsupported typst inline"),
309 }
310 }
311
312 fn link_from_call_with_body<'b, I>(
313 &mut self,
314 expr: ast::Expr<'b>,
315 exprs: &mut Peekable<I>,
316 ) -> Option<Inline>
317 where
318 I: Iterator<Item = ast::Expr<'b>>,
319 {
320 let ast::Expr::FuncCall(call) = expr else {
321 return None;
322 };
323 if self.call_name(call.callee()) != Some("link") {
324 return None;
325 }
326 let target = self.first_string_arg(call)?;
327 let label = match exprs.peek().copied() {
328 Some(ast::Expr::ContentBlock(block)) => {
329 exprs.next();
330 self.inlines_from_markup(block.body())
331 }
332 _ => vec![Inline::Text(target.clone())],
333 };
334 Some(Inline::Link { label, target })
335 }
336
337 fn table_from_call(
338 &mut self,
339 call: ast::FuncCall<'_>,
340 span: Option<Span>,
341 ) -> Option<MarkupBlock> {
342 let mut columns = None;
343 let mut cells = Vec::new();
344 for arg in call.args().items() {
345 match arg {
346 ast::Arg::Named(named) if named.name().as_str() == "columns" => {
347 if let ast::Expr::Int(value) = named.expr() {
348 columns = usize::try_from(value.get()).ok().filter(|value| *value > 0);
349 }
350 }
351 ast::Arg::Pos(ast::Expr::ContentBlock(block)) => {
352 cells.push(self.inlines_from_markup(block.body()));
353 }
354 _ => self
355 .fidelity
356 .warnings
357 .push("unsupported typst table argument preserved by source span".to_owned()),
358 }
359 }
360 if cells.is_empty() {
361 return None;
362 }
363 let width = columns.unwrap_or(cells.len()).max(1);
364 let mut rows = cells
365 .chunks(width)
366 .map(|chunk| {
367 let mut row = chunk.to_vec();
368 row.resize_with(width, Vec::new);
369 row
370 })
371 .collect::<Vec<_>>();
372 let header = rows.drain(..1).next().unwrap_or_default();
373 Some(MarkupBlock::Table { header, rows, span })
374 }
375
376 fn figure_from_call(
377 &mut self,
378 call: ast::FuncCall<'_>,
379 span: Option<Span>,
380 ) -> Option<MarkupBlock> {
381 let mut src = None;
382 let mut caption = Vec::new();
383 for arg in call.args().items() {
384 match arg {
385 ast::Arg::Pos(ast::Expr::FuncCall(image))
386 if self.call_name(image.callee()) == Some("image") =>
387 {
388 src = self.first_string_arg(image);
389 }
390 ast::Arg::Named(named) if named.name().as_str() == "caption" => {
391 if let ast::Expr::ContentBlock(block) = named.expr() {
392 caption = self.inlines_from_markup(block.body());
393 }
394 }
395 ast::Arg::Pos(ast::Expr::ContentBlock(block)) if caption.is_empty() => {
396 caption = self.inlines_from_markup(block.body());
397 }
398 _ => self
399 .fidelity
400 .warnings
401 .push("unsupported typst figure argument preserved by source span".to_owned()),
402 }
403 }
404 Some(MarkupBlock::Figure {
405 src: src?,
406 caption,
407 span,
408 })
409 }
410
411 fn first_string_arg(&self, call: ast::FuncCall<'_>) -> Option<String> {
412 call.args().items().find_map(|arg| match arg {
413 ast::Arg::Pos(ast::Expr::Str(value)) => Some(value.get().to_string()),
414 _ => None,
415 })
416 }
417
418 fn call_name<'b>(&self, expr: ast::Expr<'b>) -> Option<&'b str> {
419 match expr {
420 ast::Expr::Ident(ident) => Some(ident.as_str()),
421 _ => None,
422 }
423 }
424
425 fn flush_paragraph(&mut self, blocks: &mut Vec<MarkupBlock>, paragraph: &mut PendingParagraph) {
426 if paragraph.content.is_empty() {
427 return;
428 }
429
430 let content = std::mem::take(&mut paragraph.content);
431 let span = paragraph.span.take();
432 if inline_plain_text(&content).trim().is_empty() {
433 return;
434 }
435
436 blocks.push(MarkupBlock::Paragraph { content, span });
437 }
438
439 fn flush_list(&mut self, blocks: &mut Vec<MarkupBlock>, list: &mut Option<PendingList>) {
440 if let Some(list) = list.take() {
441 blocks.push(MarkupBlock::List {
442 ordered: list.ordered,
443 items: list.items,
444 span: list.span,
445 });
446 }
447 }
448
449 fn raw_block(
450 &mut self,
451 text: String,
452 path: &str,
453 span: Option<Span>,
454 reason: &str,
455 ) -> Option<MarkupBlock> {
456 if self.preserve_raw {
457 self.fidelity.preserved_raw.push(text.clone());
458 Some(MarkupBlock::Raw {
459 backend: typst_id(),
460 text,
461 span,
462 })
463 } else {
464 self.drop_raw(path, reason);
465 None
466 }
467 }
468
469 fn raw_inline(&mut self, text: String, path: &str, reason: &str) -> Option<Inline> {
470 if self.preserve_raw {
471 self.fidelity.preserved_raw.push(text.clone());
472 Some(Inline::Raw {
473 backend: typst_id(),
474 text,
475 })
476 } else {
477 self.drop_raw(path, reason);
478 None
479 }
480 }
481
482 fn drop_raw(&mut self, path: &str, reason: &str) {
483 self.fidelity.dropped.push(MarkupLoss {
484 path: path.to_owned(),
485 reason: reason.to_owned(),
486 });
487 }
488
489 fn span_for(&mut self, node: &SyntaxNode) -> Option<Span> {
490 let text = node.full_text();
491 if text.is_empty() {
492 return None;
493 }
494 let haystack = self.input.get(self.cursor..)?;
495 let start = haystack.find(text.as_str())? + self.cursor;
496 let end = start + text.len();
497 self.cursor = end;
498 Some(span(start, end))
499 }
500}
501
502#[derive(Default)]
503struct PendingParagraph {
504 content: Vec<Inline>,
505 span: Option<Span>,
506}
507
508impl PendingParagraph {
509 fn push(&mut self, inline: Inline, span: Option<Span>) {
510 merge_span(&mut self.span, &span);
511 self.content.push(inline);
512 }
513}
514
515struct PendingList {
516 ordered: bool,
517 items: Vec<Vec<MarkupBlock>>,
518 span: Option<Span>,
519}
520
521impl PendingList {
522 fn new(ordered: bool) -> Self {
523 Self {
524 ordered,
525 items: Vec::new(),
526 span: None,
527 }
528 }
529
530 fn push(&mut self, item: Vec<MarkupBlock>, span: Option<Span>) {
531 merge_span(&mut self.span, &span);
532 self.items.push(item);
533 }
534}
535
536fn typst_id() -> BackendId {
537 BackendId::new("typst")
538}
539
540fn expr_source(expr: ast::Expr<'_>) -> String {
541 expr.to_untyped().full_text().to_string()
542}
543
544fn raw_text(raw: ast::Raw<'_>) -> String {
545 let lines = raw
546 .lines()
547 .map(|line| line.get().to_string())
548 .collect::<Vec<_>>();
549 if lines.is_empty() {
550 raw.to_untyped().full_text().to_string()
551 } else {
552 lines.join("\n")
553 }
554}
555
556fn typst_math(math: ast::Math<'_>) -> MathSource {
557 MathSource {
558 notation: "typst".to_owned(),
559 text: math.to_untyped().full_text().trim().to_owned(),
560 }
561}
562
563fn span(start: usize, end: usize) -> Span {
564 Span {
565 start,
566 end,
567 state: SpanState::Preserved,
568 }
569}
570
571fn merge_span(target: &mut Option<Span>, next: &Option<Span>) {
572 let Some(next) = next else {
573 return;
574 };
575 match target {
576 Some(current) => {
577 current.start = current.start.min(next.start);
578 current.end = current.end.max(next.end);
579 }
580 None => *target = Some(next.clone()),
581 }
582}
583
584fn inline_plain_text(items: &[Inline]) -> String {
585 let mut text = String::new();
586 for item in items {
587 match item {
588 Inline::Text(value) | Inline::Code(value) => text.push_str(value),
589 Inline::Emph(children) | Inline::Strong(children) => {
590 text.push_str(&inline_plain_text(children));
591 }
592 Inline::Link { label, .. } => text.push_str(&inline_plain_text(label)),
593 Inline::Math(source) => text.push_str(&source.text),
594 Inline::Raw { text: raw, .. } => text.push_str(raw),
595 }
596 }
597 text
598}