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
+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) => {
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<dyn std::error::Error>> {
|
||||
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();
|
||||
|
||||
+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
|
||||
//! 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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-7
@@ -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"),
|
||||
];
|
||||
@@ -28,3 +28,4 @@ pub mod text;
|
||||
pub mod ui;
|
||||
pub mod updater;
|
||||
pub mod viewport;
|
||||
pub mod xopp;
|
||||
@@ -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"));
|
||||
|
||||
+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