diff --git a/Cargo.lock b/Cargo.lock index ab88e98..28a2b3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,10 +1397,12 @@ version = "0.35.0" dependencies = [ "ab_glyph", "arboard", + "base64", "bytemuck", "egui", "egui-wgpu", "egui-winit", + "flate2", "fs2", "glam", "hayro", @@ -1411,6 +1413,7 @@ dependencies = [ "octotablet", "pollster", "printpdf", + "quick-xml", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 805d808..a863c29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,11 @@ fs2 = "0.4" hayro = "0.8" hayro-svg = "0.8" +# Import von Xournal++-Notizbüchern (gzip-komprimiertes XML mit base64-Bildern) +flate2 = "1.1" +quick-xml = "0.41" +base64 = "0.22" + # Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui) ab_glyph = "0.2" diff --git a/README.de.md b/README.de.md index 9afe9ce..8206ad4 100644 --- a/README.de.md +++ b/README.de.md @@ -11,7 +11,7 @@ --- -ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund. +ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften und Xournal++-Notizbücher importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund. Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). @@ -47,7 +47,8 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra - **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld. - **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen. - **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle. -- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert. +- **Importieren** (☰-Menü oder Datei ins Fenster ziehen) – **PDF**: Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert. + **Xournal++** (`.xopp`, auch `.xoj` aus Xournal): Striche mit Stiftdruck, Textmarker, Texte, Bilder, liniertes/kariertes/gepunktetes Papier und PDF-Hintergründe werden übernommen (das PDF wird am alten Ort und neben der Datei gesucht). LaTeX-Formeln werden übersprungen. ### Seiten und Ansicht - **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export. @@ -169,6 +170,7 @@ src/ ├── assets.rs # Bilder und PDFs im Notizbuchordner ├── pdf_doc.rs # PDF lesen und rendern (hayro) ├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen +├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern ├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen ├── history.rs # Rückgängig/Wiederholen ├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb diff --git a/README.md b/README.md index 5f92c18..6808925 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ --- -ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background. +ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation and Xournal++ notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background. It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11). @@ -47,7 +47,8 @@ The interface is available in English and German; ink-notes follows the system l - **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`. - **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window. - **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot. -- **Import PDF** (☰ menu): Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply. +- **Import** (☰ menu, or drag the file into the window) – **PDF**: Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply. + **Xournal++** (`.xopp`, also `.xoj` from Xournal): strokes with pen pressure, highlighters, texts, images, lined/squared/dotted paper and PDF backgrounds are taken over (the PDF is looked for at its old place and next to the file). LaTeX formulas are skipped. ### Pages and view - **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports. @@ -171,6 +172,7 @@ src/ ├── assets.rs # Images and PDFs in the notebook folder ├── pdf_doc.rs # Reading and rendering PDFs (hayro) ├── pdf_merge.rs # Underlaying imported PDF pages on export +├── xopp.rs # Import of Xournal++/Xournal notebooks ├── document.rs # Data model: notebook, pages, strokes, page sizes ├── history.rs # Undo/redo ├── storage.rs # Loading/saving (atomic), notebooks, trash diff --git a/src/app.rs b/src/app.rs index 5e8d943..ba419c9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2216,10 +2216,10 @@ pub fn run() -> Result<(), Box> { } } - // Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen + // Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++: importieren WindowEvent::DroppedFile(path) => { - if crate::file_picker::is_pdf_path(&path) { - ui_state.queued_actions.push(UiAction::ImportPdf(path)); + if crate::file_picker::is_pdf_path(&path) || crate::xopp::is_xopp_path(&path) { + ui_state.queued_actions.push(UiAction::ImportFile(path)); } else if crate::assets::is_supported_image_path(&path) { let (sx, sy) = cursor_screen_pos; let center = canvas.screen_to_page_clamped(sx, sy); @@ -3837,18 +3837,23 @@ pub fn run() -> Result<(), Box> { } } } - UiAction::ImportPdf(path) => { + UiAction::ImportFile(path) => { // Neben das gerade geöffnete Notizbuch let folder = ui_state.library.folder_of(&manifest.id).to_string(); let title = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_else(|| "PDF".to_string()); - let result = std::fs::read(&path) - .map_err(|e| e.to_string()) - .and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes)); + .unwrap_or_else(|| "Import".to_string()); + let result = if crate::xopp::is_xopp_path(&path) { + crate::xopp::create_notebook_from_xopp(&storage_base_dir, &title, &path) + } else { + std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes)) + .map(|(nb, m, dir)| (nb, m, dir, crate::xopp::ImportReport::default())) + }; match result { - Ok((new_nb, new_manifest, new_dir)) => { + Ok((new_nb, new_manifest, new_dir, report)) => { let id = new_manifest.id.clone(); change_library(&storage_base_dir, &mut ui_state, |library| { library.move_notebook(&id, &folder) @@ -3867,15 +3872,22 @@ pub fn run() -> Result<(), Box> { canvas.reset_view(); canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(t!( + let mut message = t!( "'{title}' importiert ({count} Seiten)", title = manifest.title, count = notebook.pages.len() - )); + ); + if let Some(pdf) = &report.missing_pdf { + message.push_str(&t!(", PDF-Hintergrund nicht gefunden: {file}", file = pdf)); + } + if report.skipped > 0 { + message.push_str(&t!(", {count} Elemente übersprungen", count = report.skipped)); + } + ui_state.set_export_message(message); } Err(err) => { - eprintln!("[PDF-Import] '{}': {err}", path.display()); - ui_state.set_export_message(t!("PDF nicht importiert: {err}", err = err)); + eprintln!("[Import] '{}': {err}", path.display()); + ui_state.set_export_message(t!("Nicht importiert: {err}", err = err)); } } window.request_redraw(); diff --git a/src/file_picker.rs b/src/file_picker.rs index ed92b54..71d1909 100644 --- a/src/file_picker.rs +++ b/src/file_picker.rs @@ -1,4 +1,4 @@ -//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs. +//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder, PDFs und Xournal++-Dateien. //! //! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf //! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des @@ -22,8 +22,8 @@ pub struct PickerEntry { pub enum PickerKind { /// Bilder zum Einfügen auf der aktuellen Seite Image, - /// PDF zum Importieren als neues Notizbuch - Pdf, + /// PDF or Xournal++ file to import as a new notebook + Import, } impl PickerKind { @@ -31,7 +31,7 @@ impl PickerKind { pub fn accepts(self, path: &Path) -> bool { match self { PickerKind::Image => is_supported_image_path(path), - PickerKind::Pdf => is_pdf_path(path), + PickerKind::Import => is_pdf_path(path) || crate::xopp::is_xopp_path(path), } } } @@ -63,7 +63,7 @@ impl FilePicker { .or_else(|| { let preferred = match kind { PickerKind::Image => tn!("🖼 Bilder"), - PickerKind::Pdf => tn!("⬇ Downloads"), + PickerKind::Import => tn!("⬇ Downloads"), }; let all = shortcuts(); all.iter() @@ -192,6 +192,7 @@ mod tests { ".versteckt.png", "c.webp", "Skript.PDF", + "mathe.xopp", ] { std::fs::write(dir.path().join(name), b"x").unwrap(); } @@ -210,7 +211,10 @@ mod tests { names(PickerKind::Image), ["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"] ); - assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]); + assert_eq!( + names(PickerKind::Import), + ["aOrdner", "zOrdner", "mathe.xopp", "Skript.PDF"] + ); } #[test] diff --git a/src/i18n.rs b/src/i18n.rs index 650dee7..3e6c81b 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -167,6 +167,7 @@ mod tests { ("text.rs", include_str!("text.rs")), ("ui.rs", include_str!("ui.rs")), ("updater.rs", include_str!("updater.rs")), + ("xopp.rs", include_str!("xopp.rs")), ]; /// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen diff --git a/src/i18n/en.rs b/src/i18n/en.rs index 5756bee..fa97a73 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[ ("Das PDF enthält keine Seiten", "The PDF has no pages"), ("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"), ("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"), - ("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."), ("Datei existiert nicht", "File does not exist"), ("Den Ordner gibt es nicht mehr", "The folder no longer exists"), ("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"), @@ -150,7 +149,6 @@ pub const EN: &[(&str, &str)] = &[ ("Keine manifest.json vorhanden", "No manifest.json present"), ("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."), ("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"), - ("Keine Ordner oder PDF-Dateien", "No folders or PDF files"), ("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"), ("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"), ("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."), @@ -234,9 +232,7 @@ pub const EN: &[(&str, &str)] = &[ ("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"), ("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"), ("Pastell-Cyan", "Pastel cyan"), - ("PDF importieren", "Import PDF"), ("PDF kann nicht geöffnet werden", "Cannot open PDF"), - ("PDF nicht importiert: {err}", "PDF not imported: {err}"), ("PDF nicht lesbar: {e}", "PDF not readable: {e}"), ("PDF ohne Seite", "PDF without a page"), ("PDF-Export läuft…", "PDF export running…"), @@ -368,7 +364,6 @@ pub const EN: &[(&str, &str)] = &[ ("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"), ("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"), ("Vorherige Seite (Bild auf)", "Previous page (Page Up)"), - ("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"), ("weich", "soft"), ("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."), ("Weitere Funktionen – Update verfügbar", "More – update available"), @@ -430,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[ ("📂 Im Dateimanager anzeigen", "📂 Show in file manager"), ("📂 Ordner öffnen", "📂 Open folder"), ("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"), - ("📄 PDF importieren …", "📄 Import PDF …"), ("📄 Feste Seiten", "📄 Fixed pages"), - ("📄 PDF importieren", "📄 Import PDF"), ("📋 Einfügen", "📋 Paste"), ("📑 Inhalt", "📑 Contents"), ("📚 Alle", "📚 All"), @@ -454,4 +447,17 @@ pub const EN: &[(&str, &str)] = &[ ("🚀 Update {version} verfügbar", "🚀 Update {version} available"), ("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"), ("🚀 Updates & Versionen", "🚀 Updates & versions"), + (", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"), + (", {count} Elemente übersprungen", ", {count} elements skipped"), + ("Nicht importiert: {err}", "Not imported: {err}"), + ("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj): Striche, Texte, Bilder und PDF-Hintergründe werden übernommen.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj): strokes, texts, images and PDF backgrounds are taken over."), + ("Importieren", "Import"), + ("Keine Ordner, PDF- oder Xournal++-Dateien", "No folders, PDF or Xournal++ files"), + ("Vorlesungsfolien, Übungsblätter, Formulare oder Xournal++-Notizbücher als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or Xournal++ notebooks as a new notebook and write on them"), + ("📄 Importieren (PDF, Xournal++) …", "📄 Import (PDF, Xournal++) …"), + ("📄 Importieren", "📄 Import"), + ("Datei beschädigt: {err}", "File damaged: {err}"), + ("Datei nicht lesbar: {err}", "File not readable: {err}"), + ("Die Datei enthält keine Seiten", "The file contains no pages"), + ("Keine Xournal-Datei", "Not a Xournal file"), ]; diff --git a/src/lib.rs b/src/lib.rs index 3ae6944..98dfc0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,3 +28,4 @@ pub mod text; pub mod ui; pub mod updater; pub mod viewport; +pub mod xopp; diff --git a/src/ui.rs b/src/ui.rs index eefa436..518b91a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -338,7 +338,7 @@ pub enum UiAction { /// Bilddatei auf der aktuellen Seite einfügen InsertImageFile(std::path::PathBuf), /// PDF als neues Notizbuch importieren - ImportPdf(std::path::PathBuf), + ImportFile(std::path::PathBuf), /// Aktuelle Seite als Bild oder SVG exportieren ExportPage(PageExportKind), /// Titel einer Seite setzen (leer = entfernen) @@ -669,9 +669,9 @@ impl UiState { self.open_file_picker(PickerKind::Image); } - /// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner. - pub fn open_pdf_picker(&mut self) { - self.open_file_picker(PickerKind::Pdf); + /// Opens the file picker "Import" (PDF, Xournal++) in the last used folder. + pub fn open_import_picker(&mut self) { + self.open_file_picker(PickerKind::Import); } fn open_file_picker(&mut self, kind: PickerKind) { @@ -2827,14 +2827,15 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"), t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."), ), - PickerKind::Pdf => ( - t!("PDF importieren"), - t!("📄 PDF importieren"), + PickerKind::Import => ( + t!("Importieren"), + t!("📄 Importieren"), "📄", - t!("Keine Ordner oder PDF-Dateien"), + t!("Keine Ordner, PDF- oder Xournal++-Dateien"), t!( - "Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \ - Beim Export liegen deine Notizen über den Originalseiten." + "Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \ + beim Export liegen deine Notizen über den Originalseiten. \ + Xournal++ (.xopp, .xoj): Striche, Texte, Bilder und PDF-Hintergründe werden übernommen." ), ), }; @@ -2924,7 +2925,7 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone()); actions.push(match picker_kind { PickerKind::Image => UiAction::InsertImageFile(path), - PickerKind::Pdf => UiAction::ImportPdf(path), + PickerKind::Import => UiAction::ImportFile(path), }); close = true; } @@ -4195,11 +4196,11 @@ pub fn render_ui( state.open_image_picker(); } if ui - .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …"))) - .on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben")) + .add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++) …"))) + .on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Xournal++-Notizbücher als neues Notizbuch öffnen und darauf schreiben")) .clicked() { - state.open_pdf_picker(); + state.open_import_picker(); } section(ui, t!("HINTERGRUND DIESER SEITE")); diff --git a/src/xopp.rs b/src/xopp.rs new file mode 100644 index 0000000..2e9d6a7 --- /dev/null +++ b/src/xopp.rs @@ -0,0 +1,821 @@ +//! Import of Xournal++ (.xopp) and Xournal (.xoj) notebooks (Issue #47). +//! +//! Both formats are gzip-compressed XML: `` elements with a `` and +//! ``s holding ``, `` and `` elements; coordinates are PDF points. +//! The import creates a new notebook: one page per Xournal page, all layers flattened in +//! order. PDF backgrounds are taken over if the PDF can still be found; LaTeX formulas +//! (``) have no counterpart and are skipped. + +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::XmlVersion; + +use crate::document::{Notebook, Page, PageBackground, PdfBackground, StrokeTool}; +use crate::storage::{self, NotebookManifest, SampleData, StrokeData}; +use crate::t; +use crate::text::{TextBox, TextFont}; + +/// Largest accepted file after decompression (guards against gzip bombs). +const MAX_XML_BYTES: u64 = 512 * 1024 * 1024; + +/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters). +const HIGHLIGHTER_ALPHA: f32 = 0.35; + +/// Is the file by its name a Xournal++ or Xournal notebook? +pub fn is_xopp_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("xopp") || e.eq_ignore_ascii_case("xoj")) +} + +/// Parsed Xournal document, still in PDF points. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct XoppDocument { + pub pages: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct XoppPage { + pub width: f32, + pub height: f32, + pub background: XoppBackground, + pub items: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub enum XoppBackground { + #[default] + Plain, + Lined, + Graph, + Dotted, + /// Page `page` (from 0) of a PDF; `file` is only given on the first PDF page + Pdf { + file: Option, + page: u32, + }, + /// Image file as background + Pixmap { + file: String, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum XoppItem { + Stroke { + highlighter: bool, + color: [f32; 4], + /// Nominal width, then optionally one width per point (or per segment in Xournal) + widths: Vec, + points: Vec, + }, + Text { + mono: bool, + size: f32, + pos: glam::Vec2, + color: [f32; 4], + content: String, + }, + /// Embedded image (base64 PNG) with its rectangle (left, top, right, bottom) + Image { rect: [f32; 4], data: String }, + /// Element without a counterpart (LaTeX formula) + Unsupported, +} + +/// What could not be taken over. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ImportReport { + pub skipped: usize, + /// The PDF background was referenced but not found or unreadable + pub missing_pdf: Option, +} + +/// Reads the (usually gzip-compressed) XML of a Xournal file. +pub fn decompress(bytes: &[u8]) -> Result { + let mut xml = Vec::new(); + if bytes.starts_with(&[0x1f, 0x8b]) { + flate2::read::MultiGzDecoder::new(bytes) + .take(MAX_XML_BYTES) + .read_to_end(&mut xml) + .map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?; + } else { + xml = bytes.to_vec(); + } + String::from_utf8(xml).map_err(|_| t!("Keine Xournal-Datei").to_string()) +} + +fn attributes(e: &BytesStart) -> HashMap { + e.attributes() + .flatten() + .filter_map(|a| { + let value = a.normalized_value(XmlVersion::Implicit1_0).ok()?; + Some(( + String::from_utf8_lossy(a.key.as_ref()).into_owned(), + value.into_owned(), + )) + }) + .collect() +} + +fn number(attrs: &HashMap, key: &str) -> Option { + attrs + .get(key)? + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite()) +} + +/// Xournal colors: `#rrggbbaa` or one of the names of the original Xournal palette. +fn parse_color(value: &str) -> [f32; 4] { + let rgba: u32 = match value.trim() { + "black" => 0x000000ff, + "blue" => 0x3333ccff, + "red" => 0xff0000ff, + "green" => 0x008000ff, + "gray" => 0x808080ff, + "lightblue" => 0x00c0ffff, + "lightgreen" => 0x00ff00ff, + "magenta" => 0xff00ffff, + "orange" => 0xff8000ff, + "yellow" => 0xffff00ff, + "white" => 0xffffffff, + hex => { + let hex = hex.trim_start_matches('#'); + match (u32::from_str_radix(hex, 16), hex.len()) { + (Ok(v), 8) => v, + (Ok(v), 6) => (v << 8) | 0xff, + _ => 0x000000ff, + } + } + }; + [ + (rgba >> 24) as f32 / 255.0, + ((rgba >> 16) & 0xff) as f32 / 255.0, + ((rgba >> 8) & 0xff) as f32 / 255.0, + (rgba & 0xff) as f32 / 255.0, + ] +} + +fn numbers(text: &str) -> Vec { + text.split_ascii_whitespace() + .filter_map(|v| v.parse::().ok()) + .filter(|v| v.is_finite()) + .collect() +} + +/// Element whose text content is still being collected. +enum Open { + Stroke(HashMap), + Text(HashMap), + Image(HashMap), +} + +/// Parses the XML of a Xournal file. +pub fn parse(xml: &str) -> Result { + let mut reader = quick_xml::Reader::from_str(xml); + let mut doc = XoppDocument::default(); + let mut seen_root = false; + let mut open: Option = None; + let mut content = String::new(); + loop { + let event = reader + .read_event() + .map_err(|e| t!("Datei beschädigt: {err}", err = e))?; + match event { + Event::Start(ref e) | Event::Empty(ref e) => { + let is_start = matches!(event, Event::Start(_)); + let attrs = attributes(e); + match e.name().as_ref() { + b"xournal" => seen_root = true, + b"page" => doc.pages.push(XoppPage { + width: number(&attrs, "width").unwrap_or(595.0).max(1.0), + height: number(&attrs, "height").unwrap_or(842.0).max(1.0), + ..Default::default() + }), + b"background" => { + if let Some(page) = doc.pages.last_mut() { + page.background = parse_background(&attrs); + } + } + b"stroke" if is_start => open = Some(Open::Stroke(attrs)), + b"text" if is_start => open = Some(Open::Text(attrs)), + b"image" if is_start => open = Some(Open::Image(attrs)), + b"teximage" => { + if let Some(page) = doc.pages.last_mut() { + page.items.push(XoppItem::Unsupported); + } + } + _ => {} + } + content.clear(); + } + Event::Text(t) if open.is_some() => { + content.push_str(&t.decode().map_err(|e| e.to_string())?); + } + Event::CData(t) if open.is_some() => { + content.push_str(&t.decode().map_err(|e| e.to_string())?); + } + Event::GeneralRef(r) if open.is_some() => { + if let Ok(Some(c)) = r.resolve_char_ref() { + content.push(c); + } else if let Ok(name) = r.decode() { + if let Some(s) = quick_xml::escape::resolve_predefined_entity(&name) { + content.push_str(s); + } + } + } + Event::End(_) => { + if let (Some(element), Some(page)) = (open.take(), doc.pages.last_mut()) { + page.items.extend(finish_item(element, &content)); + } + content.clear(); + } + Event::Eof => break, + _ => {} + } + } + if !seen_root { + return Err(t!("Keine Xournal-Datei").to_string()); + } + Ok(doc) +} + +fn parse_background(attrs: &HashMap) -> XoppBackground { + match attrs.get("type").map(String::as_str) { + Some("pdf") => { + // Xournal++ has written page numbers like "2ll"; only the digits count + let page = attrs + .get("pageno") + .map(|v| { + v.chars() + .take_while(char::is_ascii_digit) + .collect::() + }) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + XoppBackground::Pdf { + file: attrs.get("filename").filter(|f| !f.is_empty()).cloned(), + page: page.saturating_sub(1), + } + } + Some("pixmap") => match attrs.get("filename") { + Some(file) if !file.is_empty() => XoppBackground::Pixmap { file: file.clone() }, + _ => XoppBackground::Plain, + }, + _ => match attrs.get("style").map(String::as_str) { + Some("lined" | "ruled") => XoppBackground::Lined, + Some("graph" | "isograph") => XoppBackground::Graph, + Some("dotted" | "isodotted") => XoppBackground::Dotted, + _ => XoppBackground::Plain, + }, + } +} + +fn finish_item(element: Open, content: &str) -> Option { + match element { + Open::Stroke(attrs) => { + let coords = numbers(content); + let points: Vec = coords + .as_chunks::<2>() + .0 + .iter() + .map(|&[x, y]| glam::Vec2::new(x, y)) + .collect(); + if points.is_empty() { + return None; + } + Some(XoppItem::Stroke { + highlighter: attrs.get("tool").is_some_and(|t| t == "highlighter"), + color: parse_color(attrs.get("color").map_or("black", String::as_str)), + widths: attrs.get("width").map(|w| numbers(w)).unwrap_or_default(), + points, + }) + } + Open::Text(attrs) => { + if content.trim().is_empty() { + return None; + } + Some(XoppItem::Text { + mono: attrs + .get("font") + .is_some_and(|f| f.to_ascii_lowercase().contains("mono")), + size: number(&attrs, "size").unwrap_or(12.0).max(1.0), + pos: glam::Vec2::new( + number(&attrs, "x").unwrap_or(0.0), + number(&attrs, "y").unwrap_or(0.0), + ), + color: parse_color(attrs.get("color").map_or("black", String::as_str)), + content: content.to_string(), + }) + } + Open::Image(attrs) => { + let rect = ["left", "top", "right", "bottom"].map(|k| number(&attrs, k)); + match rect { + [Some(l), Some(t), Some(r), Some(b)] => Some(XoppItem::Image { + rect: [l, t, r, b], + data: content.to_string(), + }), + _ => Some(XoppItem::Unsupported), + } + } + } +} + +/// Sample pressures and our stroke width for Xournal widths: we draw a diameter of +/// `width · (0.5 + p)`, so the widest point gets p = 1 and thinner ones less. +fn stroke_widths(widths: &[f32], count: usize, scale: f32) -> (f32, Vec) { + let nominal = widths.first().copied().unwrap_or(1.0).max(0.01) * scale; + let per_point: Vec = widths.get(1..).unwrap_or_default().to_vec(); + // Xournal++ stores one width per point, the original Xournal one per segment + if per_point.is_empty() || per_point.len() + 1 < count { + return (nominal, vec![0.5; count]); + } + let widest = per_point.iter().copied().fold(0.0, f32::max).max(0.01) * scale; + let width = widest / 1.5; + let pressures = (0..count) + .map(|i| { + let w = per_point[i.min(per_point.len() - 1)] * scale; + (w / width - 0.5).clamp(0.0, 1.0) + }) + .collect(); + (width, pressures) +} + +/// Finds a referenced file: as given, next to the Xournal file, or as Xournal++ attachment +/// (`.xopp.`). +fn find_file(name: &str, xopp_path: &Path) -> Option { + let given = PathBuf::from(name); + let dir = xopp_path.parent().unwrap_or(Path::new(".")); + let mut candidates = vec![given.clone(), dir.join(&given)]; + candidates.push(PathBuf::from(format!("{}.{name}", xopp_path.display()))); + if let Some(file_name) = given.file_name() { + candidates.push(dir.join(file_name)); + } + candidates.into_iter().find(|p| p.is_file()) +} + +/// Creates a new notebook from a Xournal++/Xournal file. +pub fn create_notebook_from_xopp( + base_dir: &Path, + title: &str, + xopp_path: &Path, +) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> { + let bytes = std::fs::read(xopp_path).map_err(|e| e.to_string())?; + let doc = parse(&decompress(&bytes)?)?; + if doc.pages.is_empty() { + return Err(t!("Die Datei enthält keine Seiten").to_string()); + } + let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?; + let result = (|| { + let (pages, next_stroke_id, report) = convert(&doc, xopp_path, &dir)?; + let page_count = pages.len() as u64; + let notebook = Notebook::from_pages(pages, 0, next_stroke_id, page_count + 1); + let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?; + storage::save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?; + Ok((notebook, manifest, report)) + })(); + match result { + Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)), + Err(err) => { + // Don't leave a half-finished notebook behind + let _ = std::fs::remove_dir_all(&dir); + Err(err) + } + } +} + +/// PDF used as background: asset name and page sizes in points. +struct PdfSource { + asset: String, + sizes: Vec<(f32, f32)>, +} + +fn convert( + doc: &XoppDocument, + xopp_path: &Path, + notebook_dir: &Path, +) -> Result<(Vec, u64, ImportReport), String> { + let assets = crate::assets::AssetStore::new(notebook_dir); + let mut report = ImportReport::default(); + let mut pdfs: HashMap> = HashMap::new(); + let mut current_pdf: Option = None; + let mut next_id: u64 = 1; + let mut pages = Vec::with_capacity(doc.pages.len()); + + for (index, xpage) in doc.pages.iter().enumerate() { + // Same rules as for imported PDFs: portrait pages become A4, the content keeps its + // aspect ratio, centred horizontally and aligned at the top + let size = crate::document::pdf_page_size(xpage.width, xpage.height); + let placement = crate::document::pdf_placement(xpage.width, xpage.height, size); + let [origin, top_right, _, bottom_left] = placement; + let scale = + ((top_right.x - origin.x) / xpage.width).min((bottom_left.y - origin.y) / xpage.height); + let map = |p: glam::Vec2| origin + p * scale; + + let mut background = PageBackground::Blank; + let mut pdf_background = None; + let mut strokes: Vec = Vec::new(); + let mut new_id = || { + next_id += 1; + next_id - 1 + }; + + match &xpage.background { + XoppBackground::Plain => {} + XoppBackground::Lined => background = PageBackground::Lined, + XoppBackground::Graph => background = PageBackground::Grid5mm, + XoppBackground::Dotted => background = PageBackground::DotGrid, + XoppBackground::Pdf { file, page } => { + if let Some(file) = file { + current_pdf = Some(file.clone()); + } + if let Some(file) = ¤t_pdf { + let source = pdfs + .entry(file.clone()) + .or_insert_with(|| load_pdf(file, xopp_path, &assets)); + match source { + Some(source) => { + if let Some(&(width_pt, height_pt)) = source.sizes.get(*page as usize) { + pdf_background = Some(PdfBackground { + asset: source.asset.clone(), + page: *page, + width_pt, + height_pt, + }); + } + } + None => report.missing_pdf = Some(file.clone()), + } + } + } + XoppBackground::Pixmap { file } => { + let image = find_file(file, xopp_path) + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| assets.import_bytes(&b).ok()); + match image { + Some(image) => strokes.push(image_data(new_id(), image.asset, placement)), + None => report.skipped += 1, + } + } + } + + for item in &xpage.items { + match item { + XoppItem::Stroke { + highlighter, + color, + widths, + points, + } => { + let (width, pressures) = stroke_widths(widths, points.len(), scale); + let mut color = *color; + let tool = if *highlighter { + if color[3] >= 0.99 { + color[3] = HIGHLIGHTER_ALPHA; + } + StrokeTool::Highlighter + } else { + // Pens are always opaque (translucent pens are read as highlighters) + color[3] = 1.0; + StrokeTool::Pen + }; + let samples = points + .iter() + .zip(pressures) + .enumerate() + .map(|(i, (&p, pressure))| { + let p = map(p); + SampleData { + x: p.x, + y: p.y, + p: pressure, + t: i as f64 * 0.005, + } + }) + .collect(); + strokes.push(StrokeData { + id: new_id(), + color, + samples, + shape: None, + tool, + width, + finalized: true, + image: None, + text: None, + }); + } + XoppItem::Text { + mono, + size, + pos, + color, + content, + } => { + let font = if *mono { + TextFont::Mono + } else { + TextFont::Sans + }; + let size = size * scale; + // Xournal doesn't wrap: as wide as the longest line + let unwrapped = TextBox::new(content.clone(), size, font, f32::MAX / 4.0); + let widest = unwrapped + .layout() + .lines + .iter() + .map(|line| line.width) + .fold(0.0, f32::max); + let text = TextBox::new(content.clone(), size, font, widest + size); + let corners = crate::text::frame_corners( + map(*pos), + [glam::Vec2::X, glam::Vec2::Y], + text.box_size(), + ); + let mut color = *color; + color[3] = 1.0; + strokes.push(StrokeData { + id: new_id(), + color, + samples: corner_samples(corners), + shape: None, + tool: StrokeTool::Pen, + width: 0.0, + finalized: true, + image: None, + text: Some(text), + }); + } + XoppItem::Image { rect, data } => { + let compact: String = + data.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + let image = base64::engine::general_purpose::STANDARD + .decode(compact) + .ok() + .and_then(|b| assets.import_bytes(&b).ok()); + match image { + Some(image) => { + let [l, t, r, b] = *rect; + let corners = [ + glam::Vec2::new(l, t), + glam::Vec2::new(r, t), + glam::Vec2::new(r, b), + glam::Vec2::new(l, b), + ] + .map(map); + strokes.push(image_data(new_id(), image.asset, corners)); + } + None => report.skipped += 1, + } + } + XoppItem::Unsupported => report.skipped += 1, + } + } + + let page_data = storage::PageData { + format_version: storage::CURRENT_FORMAT_VERSION, + id: index as u64 + 1, + background, + pdf_background, + title: None, + size: (size != crate::document::A4_SIZE).then(|| size.to_array()), + strokes, + }; + pages.push(page_data.to_page()); + } + Ok((pages, next_id, report)) +} + +fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) -> Option { + let bytes = std::fs::read(find_file(file, xopp_path)?).ok()?; + let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?; + let asset = assets.import_pdf(&bytes).ok()?; + Some(PdfSource { asset, sizes }) +} + +fn corner_samples(corners: [glam::Vec2; 4]) -> Vec { + corners + .iter() + .map(|c| SampleData { + x: c.x, + y: c.y, + p: 1.0, + t: 0.0, + }) + .collect() +} + +fn image_data(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData { + StrokeData { + id, + color: [1.0, 1.0, 1.0, 1.0], + samples: corner_samples(corners), + shape: None, + tool: StrokeTool::Pen, + width: 0.0, + finalized: true, + image: Some(asset), + text: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn gzip(xml: &str) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(xml.as_bytes()).unwrap(); + encoder.finish().unwrap() + } + + fn png_base64() -> String { + let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255])); + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + base64::engine::general_purpose::STANDARD.encode(png) + } + + const SAMPLE: &str = r##" + +Xournal++ document + + + +10 20 30 40 50 60 +0 0 100 0 + + +Hallo & Welt +zweite Zeile +AAAA + + + + + + + + + + +"##; + + #[test] + fn test_parse_xopp() { + let doc = parse(&decompress(&gzip(SAMPLE)).unwrap()).unwrap(); + assert_eq!(doc.pages.len(), 3); + let first = &doc.pages[0]; + assert_eq!(first.background, XoppBackground::Lined); + assert_eq!(first.items.len(), 4); + match &first.items[0] { + XoppItem::Stroke { + highlighter, + color, + widths, + points, + } => { + assert!(!highlighter); + assert_eq!(*color, [0.2, 0.2, 0.8, 1.0]); + assert_eq!(widths, &[1.41, 1.0, 2.0, 1.5]); + assert_eq!(points.len(), 3); + assert_eq!(points[2], glam::Vec2::new(50.0, 60.0)); + } + other => panic!("expected a stroke, got {other:?}"), + } + assert!(matches!( + first.items[1], + XoppItem::Stroke { + highlighter: true, + .. + } + )); + match &first.items[2] { + XoppItem::Text { content, size, .. } => { + assert_eq!(content, "Hallo & Welt\nzweite Zeile"); + assert_eq!(*size, 12.0); + } + other => panic!("expected a text, got {other:?}"), + } + assert_eq!(first.items[3], XoppItem::Unsupported); + assert_eq!( + doc.pages[1].background, + XoppBackground::Pdf { + file: Some("/nirgends/skript.pdf".into()), + page: 0 + } + ); + assert_eq!( + doc.pages[2].background, + XoppBackground::Pdf { + file: None, + page: 1 + } + ); + } + + #[test] + fn test_parse_rejects_other_files() { + assert!(parse("").is_err()); + assert!(decompress(&[0x1f, 0x8b, 1, 2, 3]).is_err()); + } + + #[test] + fn test_colors() { + assert_eq!(parse_color("#ff000080")[3], 128.0 / 255.0); + assert_eq!(parse_color("red"), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(parse_color("#00ff00"), [0.0, 1.0, 0.0, 1.0]); + assert_eq!(parse_color("Unsinn"), [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn test_pressure_widths_map_to_our_width_model() { + // Without pressure: nominal width, medium pressure (drawn exactly that wide) + assert_eq!(stroke_widths(&[2.0], 3, 10.0), (20.0, vec![0.5; 3])); + // With pressure: the widest point is drawn exactly as wide as in Xournal++ + let (width, pressures) = stroke_widths(&[1.0, 3.0, 1.5], 2, 10.0); + let drawn: Vec = pressures.iter().map(|p| width * (0.5 + p)).collect(); + assert!((drawn[0] - 30.0).abs() < 1e-3); + assert!((drawn[1] - 15.0).abs() < 1e-3); + // Xournal: one width per segment, the last point repeats the last one + let (_, pressures) = stroke_widths(&[1.0, 2.0, 1.0], 3, 1.0); + assert_eq!(pressures.len(), 3); + assert_eq!(pressures[1], pressures[2]); + } + + #[test] + fn test_create_notebook_from_xopp() { + let temp_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)]); + std::fs::write(source_dir.path().join("skript.pdf"), &pdf).unwrap(); + // The PDF moved next to the notebook; its image is embedded + let xml = SAMPLE.replace("\n\n", &format!( + "{}\n\n", + png_base64() + )); + let path = source_dir.path().join("Mathe.xopp"); + std::fs::write(&path, gzip(&xml)).unwrap(); + + let (notebook, manifest, dir, report) = + create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap(); + assert_eq!(manifest.title, "Mathe"); + assert_eq!(notebook.pages.len(), 3); + assert_eq!( + report, + ImportReport { + skipped: 1, + missing_pdf: None + } + ); + + let (mut reloaded, _) = storage::load_notebook(&dir).unwrap(); + storage::load_all_pages(&dir, &mut reloaded).unwrap(); + let first = &reloaded.pages[0]; + assert_eq!(first.background, PageBackground::Lined); + assert_eq!(first.size, crate::document::A4_SIZE); + assert_eq!(first.strokes.len(), 3); + let pen = &first.strokes[0]; + assert_eq!(pen.tool, StrokeTool::Pen); + // Points → page units (10 per mm): 10 pt = 35.3 units + assert!((pen.points[0].position.x - 35.28).abs() < 0.05); + assert!(!pen.vertices.is_empty()); + let marker = &first.strokes[1]; + assert_eq!(marker.tool, StrokeTool::Highlighter); + assert_eq!(marker.color[3], HIGHLIGHTER_ALPHA); + assert!(first.strokes[2].is_text()); + + for (i, page) in reloaded.pages[1..].iter().enumerate() { + let bg = page + .pdf_background + .as_ref() + .expect("PDF background missing"); + assert_eq!(bg.page, i as u32); + assert!(page.size.x > page.size.y); + } + assert!(reloaded.pages[2].strokes[0].is_image()); + // Stroke ids are unique and the notebook continues after them + assert!(reloaded.next_stroke_id > 4); + } + + #[test] + fn test_missing_pdf_is_reported() { + let temp_dir = tempfile::tempdir().unwrap(); + let source_dir = tempfile::tempdir().unwrap(); + let path = source_dir.path().join("Mathe.xoj"); + std::fs::write(&path, SAMPLE).unwrap(); + let (notebook, _, _, report) = + create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap(); + assert_eq!(notebook.pages.len(), 3); + assert_eq!(report.missing_pdf.as_deref(), Some("/nirgends/skript.pdf")); + assert!(notebook.pages[1].pdf_background.is_none()); + } +}