feat: import Xournal++ and Xournal notebooks (Issue #47)
The import menu entry (formerly "Import PDF") and drag & drop now also accept .xopp and .xoj files. Each Xournal page becomes a page of a new notebook: strokes with pen pressure, highlighters, texts, embedded images, lined/squared/dotted paper, image backgrounds and PDF backgrounds (looked up at the stored path and next to the file). All layers are flattened in order; LaTeX formulas are skipped and counted in the status message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5d9d72e79
commit
2bd697ef82
11 files changed
+902
-44
No files matched your search
Generated
+3
@@ -1397,10 +1397,12 @@ version = "0.35.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"base64",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
"egui-winit",
|
"egui-winit",
|
||||||
|
"flate2",
|
||||||
"fs2",
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
"hayro",
|
"hayro",
|
||||||
@@ -1411,6 +1413,7 @@ dependencies = [
|
|||||||
"octotablet",
|
"octotablet",
|
||||||
"pollster",
|
"pollster",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
|
"quick-xml",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ fs2 = "0.4"
|
|||||||
hayro = "0.8"
|
hayro = "0.8"
|
||||||
hayro-svg = "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)
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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).
|
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.
|
- **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.
|
- **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.
|
- **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
|
### 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.
|
- **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
|
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
||||||
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
||||||
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
├── 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
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||||
|
|||||||
@@ -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).
|
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`.
|
- **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.
|
- **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.
|
- **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
|
### 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.
|
- **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
|
├── assets.rs # Images and PDFs in the notebook folder
|
||||||
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
||||||
├── pdf_merge.rs # Underlaying imported PDF pages on export
|
├── 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
|
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||||
├── history.rs # Undo/redo
|
├── history.rs # Undo/redo
|
||||||
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
||||||
|
|||||||
+25
-13
@@ -2216,10 +2216,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
|
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++: importieren
|
||||||
WindowEvent::DroppedFile(path) => {
|
WindowEvent::DroppedFile(path) => {
|
||||||
if crate::file_picker::is_pdf_path(&path) {
|
if crate::file_picker::is_pdf_path(&path) || crate::xopp::is_xopp_path(&path) {
|
||||||
ui_state.queued_actions.push(UiAction::ImportPdf(path));
|
ui_state.queued_actions.push(UiAction::ImportFile(path));
|
||||||
} else if crate::assets::is_supported_image_path(&path) {
|
} else if crate::assets::is_supported_image_path(&path) {
|
||||||
let (sx, sy) = cursor_screen_pos;
|
let (sx, sy) = cursor_screen_pos;
|
||||||
let center = canvas.screen_to_page_clamped(sx, sy);
|
let center = canvas.screen_to_page_clamped(sx, sy);
|
||||||
@@ -3837,18 +3837,23 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::ImportPdf(path) => {
|
UiAction::ImportFile(path) => {
|
||||||
// Neben das gerade geöffnete Notizbuch
|
// Neben das gerade geöffnete Notizbuch
|
||||||
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
||||||
let title = path
|
let title = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| "PDF".to_string());
|
.unwrap_or_else(|| "Import".to_string());
|
||||||
let result = std::fs::read(&path)
|
let result = if crate::xopp::is_xopp_path(&path) {
|
||||||
.map_err(|e| e.to_string())
|
crate::xopp::create_notebook_from_xopp(&storage_base_dir, &title, &path)
|
||||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
} 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 {
|
match result {
|
||||||
Ok((new_nb, new_manifest, new_dir)) => {
|
Ok((new_nb, new_manifest, new_dir, report)) => {
|
||||||
let id = new_manifest.id.clone();
|
let id = new_manifest.id.clone();
|
||||||
change_library(&storage_base_dir, &mut ui_state, |library| {
|
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
library.move_notebook(&id, &folder)
|
library.move_notebook(&id, &folder)
|
||||||
@@ -3867,15 +3872,22 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.reset_view();
|
canvas.reset_view();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
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}' importiert ({count} Seiten)",
|
||||||
title = manifest.title,
|
title = manifest.title,
|
||||||
count = notebook.pages.len()
|
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) => {
|
Err(err) => {
|
||||||
eprintln!("[PDF-Import] '{}': {err}", path.display());
|
eprintln!("[Import] '{}': {err}", path.display());
|
||||||
ui_state.set_export_message(t!("PDF nicht importiert: {err}", err = err));
|
ui_state.set_export_message(t!("Nicht importiert: {err}", err = err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
|
|||||||
+10
-6
@@ -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
|
//! 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
|
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||||
@@ -22,8 +22,8 @@ pub struct PickerEntry {
|
|||||||
pub enum PickerKind {
|
pub enum PickerKind {
|
||||||
/// Bilder zum Einfügen auf der aktuellen Seite
|
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||||
Image,
|
Image,
|
||||||
/// PDF zum Importieren als neues Notizbuch
|
/// PDF or Xournal++ file to import as a new notebook
|
||||||
Pdf,
|
Import,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PickerKind {
|
impl PickerKind {
|
||||||
@@ -31,7 +31,7 @@ impl PickerKind {
|
|||||||
pub fn accepts(self, path: &Path) -> bool {
|
pub fn accepts(self, path: &Path) -> bool {
|
||||||
match self {
|
match self {
|
||||||
PickerKind::Image => is_supported_image_path(path),
|
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(|| {
|
.or_else(|| {
|
||||||
let preferred = match kind {
|
let preferred = match kind {
|
||||||
PickerKind::Image => tn!("🖼 Bilder"),
|
PickerKind::Image => tn!("🖼 Bilder"),
|
||||||
PickerKind::Pdf => tn!("⬇ Downloads"),
|
PickerKind::Import => tn!("⬇ Downloads"),
|
||||||
};
|
};
|
||||||
let all = shortcuts();
|
let all = shortcuts();
|
||||||
all.iter()
|
all.iter()
|
||||||
@@ -192,6 +192,7 @@ mod tests {
|
|||||||
".versteckt.png",
|
".versteckt.png",
|
||||||
"c.webp",
|
"c.webp",
|
||||||
"Skript.PDF",
|
"Skript.PDF",
|
||||||
|
"mathe.xopp",
|
||||||
] {
|
] {
|
||||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,10 @@ mod tests {
|
|||||||
names(PickerKind::Image),
|
names(PickerKind::Image),
|
||||||
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
["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]
|
#[test]
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ mod tests {
|
|||||||
("text.rs", include_str!("text.rs")),
|
("text.rs", include_str!("text.rs")),
|
||||||
("ui.rs", include_str!("ui.rs")),
|
("ui.rs", include_str!("ui.rs")),
|
||||||
("updater.rs", include_str!("updater.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
|
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
||||||
|
|||||||
+13
-7
@@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
("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 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 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"),
|
("Datei existiert nicht", "File does not exist"),
|
||||||
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
||||||
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
("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 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 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 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"),
|
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
||||||
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
("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."),
|
("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-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
||||||
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
||||||
("Pastell-Cyan", "Pastel cyan"),
|
("Pastell-Cyan", "Pastel cyan"),
|
||||||
("PDF importieren", "Import PDF"),
|
|
||||||
("PDF kann nicht geöffnet werden", "Cannot open 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 nicht lesbar: {e}", "PDF not readable: {e}"),
|
||||||
("PDF ohne Seite", "PDF without a page"),
|
("PDF ohne Seite", "PDF without a page"),
|
||||||
("PDF-Export läuft…", "PDF export running…"),
|
("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}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
||||||
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
||||||
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
("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", "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."),
|
("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"),
|
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
||||||
@@ -430,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
||||||
("📂 Ordner öffnen", "📂 Open folder"),
|
("📂 Ordner öffnen", "📂 Open folder"),
|
||||||
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||||
("📄 PDF importieren …", "📄 Import PDF …"),
|
|
||||||
("📄 Feste Seiten", "📄 Fixed pages"),
|
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||||
("📄 PDF importieren", "📄 Import PDF"),
|
|
||||||
("📋 Einfügen", "📋 Paste"),
|
("📋 Einfügen", "📋 Paste"),
|
||||||
("📑 Inhalt", "📑 Contents"),
|
("📑 Inhalt", "📑 Contents"),
|
||||||
("📚 Alle", "📚 All"),
|
("📚 Alle", "📚 All"),
|
||||||
@@ -454,4 +447,17 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
||||||
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
||||||
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
("🚀 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"),
|
||||||
];
|
];
|
||||||
@@ -28,3 +28,4 @@ pub mod text;
|
|||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
|
pub mod xopp;
|
||||||
@@ -338,7 +338,7 @@ pub enum UiAction {
|
|||||||
/// Bilddatei auf der aktuellen Seite einfügen
|
/// Bilddatei auf der aktuellen Seite einfügen
|
||||||
InsertImageFile(std::path::PathBuf),
|
InsertImageFile(std::path::PathBuf),
|
||||||
/// PDF als neues Notizbuch importieren
|
/// PDF als neues Notizbuch importieren
|
||||||
ImportPdf(std::path::PathBuf),
|
ImportFile(std::path::PathBuf),
|
||||||
/// Aktuelle Seite als Bild oder SVG exportieren
|
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||||
ExportPage(PageExportKind),
|
ExportPage(PageExportKind),
|
||||||
/// Titel einer Seite setzen (leer = entfernen)
|
/// Titel einer Seite setzen (leer = entfernen)
|
||||||
@@ -669,9 +669,9 @@ impl UiState {
|
|||||||
self.open_file_picker(PickerKind::Image);
|
self.open_file_picker(PickerKind::Image);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
|
/// Opens the file picker "Import" (PDF, Xournal++) in the last used folder.
|
||||||
pub fn open_pdf_picker(&mut self) {
|
pub fn open_import_picker(&mut self) {
|
||||||
self.open_file_picker(PickerKind::Pdf);
|
self.open_file_picker(PickerKind::Import);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
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!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
|
||||||
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
||||||
),
|
),
|
||||||
PickerKind::Pdf => (
|
PickerKind::Import => (
|
||||||
t!("PDF importieren"),
|
t!("Importieren"),
|
||||||
t!("📄 PDF importieren"),
|
t!("📄 Importieren"),
|
||||||
"📄",
|
"📄",
|
||||||
t!("Keine Ordner oder PDF-Dateien"),
|
t!("Keine Ordner, PDF- oder Xournal++-Dateien"),
|
||||||
t!(
|
t!(
|
||||||
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
|
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
|
||||||
Beim Export liegen deine Notizen über den Originalseiten."
|
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());
|
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||||
actions.push(match picker_kind {
|
actions.push(match picker_kind {
|
||||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||||
PickerKind::Pdf => UiAction::ImportPdf(path),
|
PickerKind::Import => UiAction::ImportFile(path),
|
||||||
});
|
});
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
@@ -4195,11 +4196,11 @@ pub fn render_ui(
|
|||||||
state.open_image_picker();
|
state.open_image_picker();
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …")))
|
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++) …")))
|
||||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben"))
|
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Xournal++-Notizbücher als neues Notizbuch öffnen und darauf schreiben"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
state.open_pdf_picker();
|
state.open_import_picker();
|
||||||
}
|
}
|
||||||
|
|
||||||
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
||||||
|
|||||||
+821
@@ -0,0 +1,821 @@
|
|||||||
|
//! Import of Xournal++ (.xopp) and Xournal (.xoj) notebooks (Issue #47).
|
||||||
|
//!
|
||||||
|
//! Both formats are gzip-compressed XML: `<page>` elements with a `<background>` and
|
||||||
|
//! `<layer>`s holding `<stroke>`, `<text>` and `<image>` 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
|
||||||
|
//! (`<teximage>`) 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<XoppPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct XoppPage {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub background: XoppBackground,
|
||||||
|
pub items: Vec<XoppItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
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<f32>,
|
||||||
|
points: Vec<glam::Vec2>,
|
||||||
|
},
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the (usually gzip-compressed) XML of a Xournal file.
|
||||||
|
pub fn decompress(bytes: &[u8]) -> Result<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String>, key: &str) -> Option<f32> {
|
||||||
|
attrs
|
||||||
|
.get(key)?
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.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<f32> {
|
||||||
|
text.split_ascii_whitespace()
|
||||||
|
.filter_map(|v| v.parse::<f32>().ok())
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Element whose text content is still being collected.
|
||||||
|
enum Open {
|
||||||
|
Stroke(HashMap<String, String>),
|
||||||
|
Text(HashMap<String, String>),
|
||||||
|
Image(HashMap<String, String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the XML of a Xournal file.
|
||||||
|
pub fn parse(xml: &str) -> Result<XoppDocument, String> {
|
||||||
|
let mut reader = quick_xml::Reader::from_str(xml);
|
||||||
|
let mut doc = XoppDocument::default();
|
||||||
|
let mut seen_root = false;
|
||||||
|
let mut open: Option<Open> = 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<String, String>) -> 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::<String>()
|
||||||
|
})
|
||||||
|
.and_then(|v| v.parse::<u32>().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<XoppItem> {
|
||||||
|
match element {
|
||||||
|
Open::Stroke(attrs) => {
|
||||||
|
let coords = numbers(content);
|
||||||
|
let points: Vec<glam::Vec2> = 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<f32>) {
|
||||||
|
let nominal = widths.first().copied().unwrap_or(1.0).max(0.01) * scale;
|
||||||
|
let per_point: Vec<f32> = 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
|
||||||
|
/// (`<file>.xopp.<name>`).
|
||||||
|
fn find_file(name: &str, xopp_path: &Path) -> Option<PathBuf> {
|
||||||
|
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<Page>, u64, ImportReport), String> {
|
||||||
|
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||||
|
let mut report = ImportReport::default();
|
||||||
|
let mut pdfs: HashMap<String, Option<PdfSource>> = HashMap::new();
|
||||||
|
let mut current_pdf: Option<String> = 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<StrokeData> = 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<PdfSource> {
|
||||||
|
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<SampleData> {
|
||||||
|
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<u8> {
|
||||||
|
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##"<?xml version="1.0" standalone="no"?>
|
||||||
|
<xournal creator="Xournal++ 1.2.3" fileversion="4">
|
||||||
|
<title>Xournal++ document</title>
|
||||||
|
<page width="595.27559" height="841.88976">
|
||||||
|
<background type="solid" color="#ffffffff" style="lined"/>
|
||||||
|
<layer>
|
||||||
|
<stroke tool="pen" ts="0" fn="" color="#3333ccff" width="1.41 1.0 2.0 1.5" capStyle="round">10 20 30 40 50 60</stroke>
|
||||||
|
<stroke tool="highlighter" color="#ffff00ff" width="8.5">0 0 100 0</stroke>
|
||||||
|
</layer>
|
||||||
|
<layer>
|
||||||
|
<text font="Sans" size="12" x="72" y="100" color="#000000ff">Hallo & Welt
|
||||||
|
zweite Zeile</text>
|
||||||
|
<teximage text="x^2" left="0" top="0" right="10" bottom="10">AAAA</teximage>
|
||||||
|
</layer>
|
||||||
|
</page>
|
||||||
|
<page width="841.88976" height="595.27559">
|
||||||
|
<background type="pdf" domain="absolute" filename="/nirgends/skript.pdf" pageno="1ll"/>
|
||||||
|
<layer/>
|
||||||
|
</page>
|
||||||
|
<page width="841.88976" height="595.27559">
|
||||||
|
<background type="pdf" pageno="2ll"/>
|
||||||
|
<layer/>
|
||||||
|
</page>
|
||||||
|
</xournal>"##;
|
||||||
|
|
||||||
|
#[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("<svg></svg>").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<f32> = 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("<layer/>\n</page>\n</xournal>", &format!(
|
||||||
|
"<layer><image left=\"0\" top=\"0\" right=\"40\" bottom=\"20\">{}</image></layer>\n</page>\n</xournal>",
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user