Skip to main content

dais_document/
typst_export.rs

1//! Typst-based annotated slide export.
2//!
3//! This module composes source PDF pages, ink annotations, whiteboard ink, and
4//! Typst text boxes into a new Typst document and exports it to PDF, SVG, or PNG.
5
6use 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/// Output format for annotated exports.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ExportFormat {
36    Pdf,
37    Svg,
38    Png,
39}
40
41/// Content layers to include in the export.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ExportLayers {
44    /// Source PDF pages only.
45    Background,
46    /// Slide ink only.
47    Ink,
48    /// Text boxes only.
49    Text,
50    /// Slide ink and text boxes without source PDF pages.
51    Overlays,
52    /// Source PDF pages, slide ink, and text boxes.
53    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/// Whiteboard export behavior.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum WhiteboardExport {
73    None,
74    Append,
75    Only,
76}
77
78/// Inputs for annotated export.
79#[derive(Debug, Clone, Copy)]
80pub struct AnnotatedExport<'a> {
81    /// The source PDF path.
82    pub pdf_path: &'a Path,
83    /// The metadata containing annotations and text boxes.
84    pub metadata: &'a PresentationMetadata,
85    /// Output format.
86    pub format: ExportFormat,
87    /// Content layers to include.
88    pub layers: ExportLayers,
89    /// Collapse incremental/build pages to one page per logical slide.
90    pub handout: bool,
91    /// Whiteboard export behavior.
92    pub whiteboard: WhiteboardExport,
93}
94
95/// A rendered export artifact.
96#[derive(Debug, Clone)]
97pub struct ExportArtifact {
98    pub name: String,
99    pub bytes: Vec<u8>,
100}
101
102/// Errors produced while composing or exporting annotated slides.
103#[derive(Debug, Error)]
104pub enum TypstExportError {
105    /// The input PDF could not be read.
106    #[error("failed to read source PDF: {0}")]
107    ReadPdf(#[from] std::io::Error),
108    /// The source PDF could not be opened for page geometry.
109    #[error("failed to open source PDF: {0}")]
110    OpenPdf(#[from] crate::source::DocumentError),
111    /// Typst compilation failed.
112    #[error("Typst compilation failed: {0}")]
113    Compile(String),
114    /// Typst PDF serialization failed.
115    #[error("Typst PDF export failed: {0}")]
116    Pdf(String),
117    /// PNG serialization failed.
118    #[error("PNG export failed: {0}")]
119    Png(String),
120}
121
122/// Export annotated slides.
123pub 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
131/// Export annotated slides from an already-open document and source PDF bytes.
132pub 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}