1use std::fmt::Write as _;
7use std::path::Path;
8use std::sync::LazyLock;
9
10use dais_sidecar::types::{InkStrokeMeta, PresentationMetadata, TextBoxMeta};
11use image::codecs::png::PngEncoder;
12use image::{ColorType, ImageEncoder};
13use thiserror::Error;
14use typst::Library;
15use typst::LibraryExt;
16use typst::diag::{FileError, FileResult};
17use typst::foundations::{Bytes, Datetime};
18use typst::layout::PagedDocument;
19use typst::syntax::{FileId, Source, VirtualPath};
20use typst::text::{Font, FontBook};
21use typst::utils::LazyHash;
22use typst_kit::fonts::{FontSearcher, Fonts};
23
24use crate::pdf_hayro::HayroDocument;
25use crate::source::DocumentSource;
26
27const MAIN_TYP: &str = "main.typ";
28const SOURCE_PDF: &str = "source.pdf";
29const PNG_PIXELS_PER_PT: f32 = 2.0;
30
31static FONTS: LazyLock<Fonts> = LazyLock::new(|| FontSearcher::new().search());
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ExportFormat {
36 Pdf,
37 Svg,
38 Png,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ExportLayers {
44 Background,
46 Ink,
48 Text,
50 Overlays,
52 All,
54}
55
56impl ExportLayers {
57 fn background(self) -> bool {
58 matches!(self, Self::Background | Self::All)
59 }
60
61 fn ink(self) -> bool {
62 matches!(self, Self::Ink | Self::Overlays | Self::All)
63 }
64
65 fn text(self) -> bool {
66 matches!(self, Self::Text | Self::Overlays | Self::All)
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum WhiteboardExport {
73 None,
74 Append,
75 Only,
76}
77
78#[derive(Debug, Clone, Copy)]
80pub struct AnnotatedExport<'a> {
81 pub pdf_path: &'a Path,
83 pub metadata: &'a PresentationMetadata,
85 pub format: ExportFormat,
87 pub layers: ExportLayers,
89 pub handout: bool,
91 pub whiteboard: WhiteboardExport,
93}
94
95#[derive(Debug, Clone)]
97pub struct ExportArtifact {
98 pub name: String,
99 pub bytes: Vec<u8>,
100}
101
102#[derive(Debug, Error)]
104pub enum TypstExportError {
105 #[error("failed to read source PDF: {0}")]
107 ReadPdf(#[from] std::io::Error),
108 #[error("failed to open source PDF: {0}")]
110 OpenPdf(#[from] crate::source::DocumentError),
111 #[error("Typst compilation failed: {0}")]
113 Compile(String),
114 #[error("Typst PDF export failed: {0}")]
116 Pdf(String),
117 #[error("PNG export failed: {0}")]
119 Png(String),
120}
121
122pub fn export_annotated(
124 request: AnnotatedExport<'_>,
125) -> Result<Vec<ExportArtifact>, TypstExportError> {
126 let pdf_bytes = std::fs::read(request.pdf_path)?;
127 let doc = HayroDocument::open(request.pdf_path)?;
128 export_annotated_from_bytes(&doc, pdf_bytes, request)
129}
130
131pub fn export_annotated_from_bytes(
133 doc: &dyn DocumentSource,
134 pdf_bytes: Vec<u8>,
135 request: AnnotatedExport<'_>,
136) -> Result<Vec<ExportArtifact>, TypstExportError> {
137 let source = build_annotated_typst_source(
138 doc,
139 request.metadata,
140 request.layers,
141 request.handout,
142 request.whiteboard,
143 );
144 let document = compile_typst_document(source, pdf_bytes)?;
145 match request.format {
146 ExportFormat::Pdf => Ok(vec![ExportArtifact {
147 name: "export.pdf".to_string(),
148 bytes: export_pdf(&document)?,
149 }]),
150 ExportFormat::Svg => Ok(export_svg(&document)),
151 ExportFormat::Png => export_png(&document),
152 }
153}
154
155fn compile_typst_document(
156 source: String,
157 pdf_bytes: Vec<u8>,
158) -> Result<PagedDocument, TypstExportError> {
159 let world = ExportWorld::new(source, pdf_bytes);
160 let result = typst::compile::<PagedDocument>(&world);
161 result.output.map_err(|errors| TypstExportError::Compile(format!("{errors:?}")))
162}
163
164fn export_pdf(document: &PagedDocument) -> Result<Vec<u8>, TypstExportError> {
165 typst_pdf::pdf(document, &typst_pdf::PdfOptions::default())
166 .map_err(|errors| TypstExportError::Pdf(format!("{errors:?}")))
167}
168
169fn export_svg(document: &PagedDocument) -> Vec<ExportArtifact> {
170 document
171 .pages
172 .iter()
173 .enumerate()
174 .map(|(index, page)| ExportArtifact {
175 name: numbered_name(index, "svg"),
176 bytes: typst_svg::svg(page).into_bytes(),
177 })
178 .collect()
179}
180
181fn export_png(document: &PagedDocument) -> Result<Vec<ExportArtifact>, TypstExportError> {
182 document
183 .pages
184 .iter()
185 .enumerate()
186 .map(|(index, page)| {
187 let pixmap = typst_render::render(page, PNG_PIXELS_PER_PT);
188 let mut bytes = Vec::new();
189 PngEncoder::new(&mut bytes)
190 .write_image(
191 pixmap.data(),
192 pixmap.width(),
193 pixmap.height(),
194 ColorType::Rgba8.into(),
195 )
196 .map_err(|err| TypstExportError::Png(err.to_string()))?;
197 Ok(ExportArtifact { name: numbered_name(index, "png"), bytes })
198 })
199 .collect()
200}
201
202fn numbered_name(index: usize, extension: &str) -> String {
203 format!("page-{:03}.{extension}", index + 1)
204}
205
206struct ExportWorld {
207 library: LazyHash<Library>,
208 book: LazyHash<FontBook>,
209 main_id: FileId,
210 pdf_id: FileId,
211 source: Source,
212 pdf_bytes: Bytes,
213}
214
215impl ExportWorld {
216 fn new(markup: String, pdf_bytes: Vec<u8>) -> Self {
217 let main_id = FileId::new(None, VirtualPath::new(MAIN_TYP));
218 let pdf_id = FileId::new(None, VirtualPath::new(SOURCE_PDF));
219 let source = Source::new(main_id, markup);
220 let fonts = &*FONTS;
221 Self {
222 library: LazyHash::new(Library::default()),
223 book: LazyHash::new(fonts.book.clone()),
224 main_id,
225 pdf_id,
226 source,
227 pdf_bytes: Bytes::new(pdf_bytes),
228 }
229 }
230}
231
232impl typst_library::World for ExportWorld {
233 fn library(&self) -> &LazyHash<Library> {
234 &self.library
235 }
236
237 fn book(&self) -> &LazyHash<FontBook> {
238 &self.book
239 }
240
241 fn main(&self) -> FileId {
242 self.main_id
243 }
244
245 fn source(&self, id: FileId) -> FileResult<Source> {
246 if id == self.main_id {
247 Ok(self.source.clone())
248 } else {
249 Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
250 }
251 }
252
253 fn file(&self, id: FileId) -> FileResult<Bytes> {
254 if id == self.pdf_id {
255 Ok(self.pdf_bytes.clone())
256 } else {
257 Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
258 }
259 }
260
261 fn font(&self, index: usize) -> Option<Font> {
262 FONTS.fonts.get(index)?.get()
263 }
264
265 fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
266 None
267 }
268}
269
270fn build_annotated_typst_source(
271 doc: &dyn DocumentSource,
272 metadata: &PresentationMetadata,
273 layers: ExportLayers,
274 handout: bool,
275 whiteboard: WhiteboardExport,
276) -> String {
277 let items = export_items(doc, metadata, handout, whiteboard);
278 let mut out = String::from("#set page(margin: 0pt)\n#set par(leading: 0pt)\n");
279
280 for item in &items {
281 write_page_source(&mut out, item, layers);
282 }
283 out
284}
285
286fn export_items<'a>(
287 doc: &dyn DocumentSource,
288 metadata: &'a PresentationMetadata,
289 handout: bool,
290 whiteboard: WhiteboardExport,
291) -> Vec<ExportItem<'a>> {
292 let mut items = Vec::new();
293 if !matches!(whiteboard, WhiteboardExport::Only) {
294 for page_index in selected_page_indices(doc.page_count(), metadata, handout) {
295 let dims = doc.page_dimensions(page_index);
296 items.push(ExportItem {
297 width_pts: dims.width_pts,
298 height_pts: dims.height_pts,
299 content: ExportItemContent::Slide {
300 page_index,
301 strokes: metadata.slide_annotations.get(&page_index).map_or(&[], Vec::as_slice),
302 text_boxes: metadata
303 .slide_text_boxes
304 .get(&page_index)
305 .map_or(&[], Vec::as_slice),
306 },
307 });
308 }
309 }
310
311 if matches!(whiteboard, WhiteboardExport::Append | WhiteboardExport::Only) {
312 let dims = if doc.page_count() > 0 {
313 doc.page_dimensions(0)
314 } else {
315 crate::page::PageDimensions { width_pts: 960.0, height_pts: 540.0 }
316 };
317 items.push(ExportItem {
318 width_pts: dims.width_pts,
319 height_pts: dims.height_pts,
320 content: ExportItemContent::Whiteboard { strokes: &metadata.whiteboard_annotations },
321 });
322 }
323 items
324}
325
326fn selected_page_indices(
327 page_count: usize,
328 metadata: &PresentationMetadata,
329 handout: bool,
330) -> Vec<usize> {
331 if !handout {
332 return (0..page_count).collect();
333 }
334
335 if metadata.groups.is_empty() {
336 return (0..page_count).collect();
337 }
338
339 metadata
340 .groups
341 .iter()
342 .filter_map(|group| (group.end_page < page_count).then_some(group.end_page))
343 .collect()
344}
345
346struct ExportItem<'a> {
347 width_pts: f32,
348 height_pts: f32,
349 content: ExportItemContent<'a>,
350}
351
352enum ExportItemContent<'a> {
353 Slide { page_index: usize, strokes: &'a [InkStrokeMeta], text_boxes: &'a [TextBoxMeta] },
354 Whiteboard { strokes: &'a [InkStrokeMeta] },
355}
356
357fn write_page_source(out: &mut String, item: &ExportItem<'_>, layers: ExportLayers) {
358 writeln!(
359 out,
360 "#page(width: {}pt, height: {}pt, margin: 0pt, fill: white)[",
361 fmt(item.width_pts),
362 fmt(item.height_pts)
363 )
364 .expect("writing to String cannot fail");
365 write_item_source(out, item, layers);
366 out.push_str("]\n");
367}
368
369fn write_item_source(out: &mut String, item: &ExportItem<'_>, layers: ExportLayers) {
370 match &item.content {
371 ExportItemContent::Slide { page_index, strokes, text_boxes } => {
372 if layers.background() {
373 write_pdf_background_source(out, *page_index, item.width_pts, item.height_pts);
374 }
375 if layers.ink() {
376 for stroke in *strokes {
377 write_stroke_source(out, stroke, item.width_pts, item.height_pts);
378 }
379 }
380 if layers.text() {
381 for text_box in *text_boxes {
382 write_text_box_source(out, text_box, item.width_pts, item.height_pts);
383 }
384 }
385 }
386 ExportItemContent::Whiteboard { strokes } => {
387 writeln!(
388 out,
389 "#place(dx: 0pt, dy: 0pt, rect(width: {}pt, height: {}pt, fill: white))",
390 fmt(item.width_pts),
391 fmt(item.height_pts)
392 )
393 .expect("writing to String cannot fail");
394 if layers.ink() || matches!(layers, ExportLayers::All | ExportLayers::Background) {
395 for stroke in *strokes {
396 write_stroke_source(out, stroke, item.width_pts, item.height_pts);
397 }
398 }
399 }
400 }
401}
402
403fn write_pdf_background_source(
404 out: &mut String,
405 page_index: usize,
406 width_pts: f32,
407 height_pts: f32,
408) {
409 writeln!(
410 out,
411 "#place(dx: 0pt, dy: 0pt, image({}, format: \"pdf\", page: {}, width: {}pt, height: {}pt, fit: \"stretch\"))",
412 typst_string(SOURCE_PDF),
413 page_index + 1,
414 fmt(width_pts),
415 fmt(height_pts)
416 )
417 .expect("writing to String cannot fail");
418}
419
420fn write_stroke_source(out: &mut String, stroke: &InkStrokeMeta, width_pts: f32, height_pts: f32) {
421 let color = typst_rgba(stroke.color);
422 let thickness = fmt(stroke.width);
423 if stroke.points.len() >= 2 {
424 write!(
425 out,
426 "#curve(stroke: (paint: {color}, thickness: {thickness}pt, cap: \"round\", join: \"round\")"
427 )
428 .expect("writing to String cannot fail");
429 let (x, y) = denormalize(stroke.points[0], width_pts, height_pts);
430 write!(out, ", curve.move(({}pt, {}pt))", fmt(x), fmt(y))
431 .expect("writing to String cannot fail");
432 for &point in &stroke.points[1..] {
433 let (x, y) = denormalize(point, width_pts, height_pts);
434 write!(out, ", curve.line(({}pt, {}pt))", fmt(x), fmt(y))
435 .expect("writing to String cannot fail");
436 }
437 out.push_str(")\n");
438 }
439
440 let radius = stroke.width * 0.5;
441 for &point in &stroke.points {
442 let (x, y) = denormalize(point, width_pts, height_pts);
443 writeln!(
444 out,
445 "#place(dx: {}pt, dy: {}pt, circle(radius: {}pt, fill: {color}))",
446 fmt(x - radius),
447 fmt(y - radius),
448 fmt(radius)
449 )
450 .expect("writing to String cannot fail");
451 }
452}
453
454fn write_text_box_source(
455 out: &mut String,
456 text_box: &TextBoxMeta,
457 width_pts: f32,
458 height_pts: f32,
459) {
460 let (x, y, w, h) = text_box.rect;
461 let x = x * width_pts;
462 let y = y * height_pts;
463 let w = w * width_pts;
464 let h = h * height_pts;
465 let fill = text_box.background.map_or_else(|| "none".to_string(), typst_rgba);
466 let text_color = typst_rgba(text_box.color);
467
468 writeln!(
469 out,
470 "#place(dx: {}pt, dy: {}pt, block(width: {}pt, height: {}pt, fill: {fill}, inset: 0pt)[",
471 fmt(x),
472 fmt(y),
473 fmt(w),
474 fmt(h)
475 )
476 .expect("writing to String cannot fail");
477 writeln!(out, "#set text(size: {}pt, fill: {text_color})", fmt(text_box.font_size))
478 .expect("writing to String cannot fail");
479 if !text_box.typst_prelude.trim().is_empty() {
480 out.push_str(&text_box.typst_prelude);
481 out.push('\n');
482 }
483 out.push_str(&text_box.content);
484 out.push_str("\n])\n");
485}
486
487fn denormalize(point: (f32, f32), width_pts: f32, height_pts: f32) -> (f32, f32) {
488 (point.0 * width_pts, point.1 * height_pts)
489}
490
491fn typst_rgba([r, g, b, a]: [u8; 4]) -> String {
492 format!("rgb({r}, {g}, {b}, {a})")
493}
494
495fn typst_string(value: &str) -> String {
496 let mut escaped = String::with_capacity(value.len() + 2);
497 escaped.push('"');
498 for ch in value.chars() {
499 match ch {
500 '\\' => escaped.push_str("\\\\"),
501 '"' => escaped.push_str("\\\""),
502 '\n' => escaped.push_str("\\n"),
503 '\r' => escaped.push_str("\\r"),
504 '\t' => escaped.push_str("\\t"),
505 _ => escaped.push(ch),
506 }
507 }
508 escaped.push('"');
509 escaped
510}
511
512fn fmt(value: f32) -> String {
513 let mut s = format!("{value:.4}");
514 while s.contains('.') && s.ends_with('0') {
515 s.pop();
516 }
517 if s.ends_with('.') {
518 s.pop();
519 }
520 if s == "-0" { "0".to_string() } else { s }
521}
522
523#[cfg(test)]
524mod tests {
525 use std::collections::HashMap;
526
527 use super::*;
528 use crate::page::{PageDimensions, RenderSize, RenderedPage};
529 use crate::source::{DocumentError, EmbeddedMetadata, OutlineEntry};
530
531 struct StubDocument {
532 pages: Vec<PageDimensions>,
533 }
534
535 impl DocumentSource for StubDocument {
536 fn page_count(&self) -> usize {
537 self.pages.len()
538 }
539
540 fn page_dimensions(&self, page_index: usize) -> PageDimensions {
541 self.pages[page_index]
542 }
543
544 fn render_page(
545 &self,
546 _page_index: usize,
547 _target_size: RenderSize,
548 ) -> Result<RenderedPage, DocumentError> {
549 unimplemented!("export tests do not render pages")
550 }
551
552 fn embedded_metadata(&self) -> Option<EmbeddedMetadata> {
553 None
554 }
555
556 fn outline(&self) -> Option<Vec<OutlineEntry>> {
557 None
558 }
559 }
560
561 fn one_page_doc() -> StubDocument {
562 StubDocument { pages: vec![PageDimensions { width_pts: 200.0, height_pts: 100.0 }] }
563 }
564
565 fn request(metadata: &PresentationMetadata, format: ExportFormat) -> AnnotatedExport<'_> {
566 AnnotatedExport {
567 pdf_path: Path::new("slides.pdf"),
568 metadata,
569 format,
570 layers: ExportLayers::All,
571 handout: false,
572 whiteboard: WhiteboardExport::None,
573 }
574 }
575
576 #[test]
577 fn basic_typst_export_produces_pdf_bytes() {
578 let document = compile_typst_document(
579 "#set page(width: 100pt, height: 50pt, margin: 0pt)\nHello".to_string(),
580 Vec::new(),
581 )
582 .expect("basic Typst document should compile");
583 let pdf = export_pdf(&document).expect("basic Typst document should export");
584
585 assert!(pdf.starts_with(b"%PDF"));
586 }
587
588 #[test]
589 fn ink_source_maps_points_and_preserves_style() {
590 let mut annotations = HashMap::new();
591 annotations.insert(
592 0,
593 vec![InkStrokeMeta {
594 points: vec![(0.0, 0.0), (1.0, 1.0)],
595 color: [255, 128, 0, 77],
596 width: 3.5,
597 }],
598 );
599 let meta = PresentationMetadata { slide_annotations: annotations, ..Default::default() };
600
601 let source = build_annotated_typst_source(
602 &one_page_doc(),
603 &meta,
604 ExportLayers::All,
605 false,
606 WhiteboardExport::None,
607 );
608
609 assert!(source.contains("rgb(255, 128, 0, 77)"));
610 assert!(source.contains("thickness: 3.5pt"));
611 assert!(source.contains("curve.move((0pt, 0pt))"));
612 assert!(source.contains("curve.line((200pt, 100pt))"));
613 assert!(source.contains("circle(radius: 1.75pt"));
614 }
615
616 #[test]
617 fn single_point_stroke_emits_dot_without_curve() {
618 let mut annotations = HashMap::new();
619 annotations.insert(
620 0,
621 vec![InkStrokeMeta { points: vec![(0.5, 0.5)], color: [0, 0, 255, 255], width: 4.0 }],
622 );
623 let meta = PresentationMetadata { slide_annotations: annotations, ..Default::default() };
624
625 let source = build_annotated_typst_source(
626 &one_page_doc(),
627 &meta,
628 ExportLayers::All,
629 false,
630 WhiteboardExport::None,
631 );
632
633 assert!(!source.contains("#curve("));
634 assert!(source.contains("#place(dx: 98pt, dy: 48pt, circle(radius: 2pt"));
635 }
636
637 #[test]
638 fn text_box_source_maps_rect_and_preserves_typst_content() {
639 let mut boxes = HashMap::new();
640 boxes.insert(
641 0,
642 vec![TextBoxMeta {
643 id: 1,
644 rect: (0.1, 0.2, 0.3, 0.4),
645 content: "$pi r^2$".to_string(),
646 font_size: 18.0,
647 color: [1, 2, 3, 255],
648 background: Some([240, 241, 242, 128]),
649 typst_prelude: "#set align(horizon)".to_string(),
650 }],
651 );
652 let meta = PresentationMetadata { slide_text_boxes: boxes, ..Default::default() };
653
654 let source = build_annotated_typst_source(
655 &one_page_doc(),
656 &meta,
657 ExportLayers::All,
658 false,
659 WhiteboardExport::None,
660 );
661
662 assert!(source.contains("#place(dx: 20pt, dy: 20pt, block(width: 60pt, height: 40pt"));
663 assert!(source.contains("fill: rgb(240, 241, 242, 128)"));
664 assert!(source.contains("#set text(size: 18pt, fill: rgb(1, 2, 3, 255))"));
665 assert!(source.contains("#set align(horizon)"));
666 assert!(source.contains("$pi r^2$"));
667 }
668
669 #[test]
670 fn layer_selection_can_omit_background() {
671 let source = build_annotated_typst_source(
672 &one_page_doc(),
673 &PresentationMetadata::default(),
674 ExportLayers::Overlays,
675 false,
676 WhiteboardExport::None,
677 );
678
679 assert!(!source.contains("image(\"source.pdf\""));
680 }
681
682 #[test]
683 fn pdf_background_source_uses_pdf_image_page() {
684 let source = build_annotated_typst_source(
685 &one_page_doc(),
686 &PresentationMetadata::default(),
687 ExportLayers::All,
688 false,
689 WhiteboardExport::None,
690 );
691
692 assert!(source.contains("image(\"source.pdf\", format: \"pdf\", page: 1"));
693 assert!(source.contains("width: 200pt, height: 100pt, fit: \"stretch\""));
694 }
695
696 #[test]
697 fn whiteboard_append_adds_page() {
698 let meta = PresentationMetadata {
699 whiteboard_annotations: vec![InkStrokeMeta {
700 points: vec![(0.0, 0.0), (1.0, 1.0)],
701 color: [0, 0, 0, 255],
702 width: 2.0,
703 }],
704 ..Default::default()
705 };
706
707 let source = build_annotated_typst_source(
708 &one_page_doc(),
709 &meta,
710 ExportLayers::All,
711 false,
712 WhiteboardExport::Append,
713 );
714
715 assert_eq!(source.matches("#page(").count(), 2);
716 }
717
718 #[test]
719 fn handout_uses_final_page_of_each_logical_slide() {
720 let doc = StubDocument {
721 pages: vec![
722 PageDimensions { width_pts: 200.0, height_pts: 100.0 },
723 PageDimensions { width_pts: 200.0, height_pts: 100.0 },
724 PageDimensions { width_pts: 200.0, height_pts: 100.0 },
725 ],
726 };
727 let meta = PresentationMetadata {
728 groups: vec![
729 dais_sidecar::types::SlideGroupMeta { start_page: 0, end_page: 1 },
730 dais_sidecar::types::SlideGroupMeta { start_page: 2, end_page: 2 },
731 ],
732 ..Default::default()
733 };
734 let source = build_annotated_typst_source(
735 &doc,
736 &meta,
737 ExportLayers::All,
738 true,
739 WhiteboardExport::None,
740 );
741
742 assert_eq!(source.matches("#page(").count(), 2);
743 assert!(!source.contains("page: 1,"));
744 assert!(source.contains("page: 2,"));
745 assert!(source.contains("page: 3,"));
746 }
747
748 #[test]
749 fn svg_and_png_exports_return_one_artifact_per_page() {
750 let pdf = std::fs::read(
751 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/test.pdf"),
752 )
753 .unwrap();
754 let meta = PresentationMetadata::default();
755 let svg = export_annotated_from_bytes(
756 &one_page_doc(),
757 pdf.clone(),
758 request(&meta, ExportFormat::Svg),
759 )
760 .expect("svg export should work");
761 let png =
762 export_annotated_from_bytes(&one_page_doc(), pdf, request(&meta, ExportFormat::Png))
763 .expect("png export should work");
764
765 assert_eq!(svg.len(), 1);
766 assert!(svg[0].bytes.starts_with(b"<svg"));
767 assert_eq!(png.len(), 1);
768 assert!(png[0].bytes.starts_with(b"\x89PNG"));
769 }
770}