diff --git a/Cargo.lock b/Cargo.lock index 28a2b3c..f56a8fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -539,6 +539,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deranged" version = "0.5.8" @@ -790,6 +796,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "foldhash" version = "0.2.0" @@ -1381,6 +1393,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "indexmap" version = "2.14.2" @@ -1414,6 +1432,7 @@ dependencies = [ "pollster", "printpdf", "quick-xml", + "resvg", "semver", "serde", "serde_json", @@ -2082,6 +2101,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2329,6 +2354,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -2343,6 +2395,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2453,7 +2514,7 @@ dependencies = [ "log", "memmap2", "smithay-client-toolkit", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -2534,6 +2595,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.4" @@ -2641,6 +2711,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "strum" @@ -2670,6 +2743,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "1.0.109" @@ -2831,7 +2914,22 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "tiny-skia-path", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", ] [[package]] @@ -2845,6 +2943,17 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2981,6 +3090,25 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c" +dependencies = [ + "data-url", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", +] + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index a863c29..f54e067 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ hayro-svg = "0.8" flate2 = "1.1" quick-xml = "0.41" base64 = "0.22" +# Rnote-Import: SVG-Bilder (z. B. PDF-Seiten) rastern +resvg = { version = "0.49", default-features = false, features = ["raster-images"] } # Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui) ab_glyph = "0.2" diff --git a/README.de.md b/README.de.md index 8206ad4..f4e6a17 100644 --- a/README.de.md +++ b/README.de.md @@ -11,7 +11,7 @@ --- -ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften und Xournal++-Notizbücher 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 sowie Notizbücher aus Xournal++ und Rnote 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). @@ -49,6 +49,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra - **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. - **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. + **Rnote** (`.rnote`, ab Rnote 0.5.10): Striche mit Stiftdruck, Formen, Texte und Bilder; in Rnote importierte PDF-Seiten werden zu Bildern. Seiten bleiben Seiten, unendliche Dokumente werden ein endloses Notizbuch. ### 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. @@ -170,7 +171,9 @@ src/ ├── assets.rs # Bilder und PDFs im Notizbuchordner ├── pdf_doc.rs # PDF lesen und rendern (hayro) ├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen +├── import.rs # Import als neues Notizbuch (Gemeinsames) ├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern +├── rnote.rs # Import von Rnote-Notizbüchern ├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen ├── history.rs # Rückgängig/Wiederholen ├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb diff --git a/README.md b/README.md index 6808925..88fb76e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ --- -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. +ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++ and Rnote 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). @@ -49,6 +49,7 @@ The interface is available in English and German; ink-notes follows the system l - **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** (☰ 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. + **Rnote** (`.rnote`, from Rnote 0.5.10): strokes with pen pressure, shapes, texts and images; PDF pages imported in Rnote become images. Pages stay pages, infinite documents become an endless notebook. ### 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. @@ -172,7 +173,9 @@ src/ ├── assets.rs # Images and PDFs in the notebook folder ├── pdf_doc.rs # Reading and rendering PDFs (hayro) ├── pdf_merge.rs # Underlaying imported PDF pages on export +├── import.rs # Import as a new notebook (shared parts) ├── xopp.rs # Import of Xournal++/Xournal notebooks +├── rnote.rs # Import of Rnote notebooks ├── document.rs # Data model: notebook, pages, strokes, page sizes ├── history.rs # Undo/redo ├── storage.rs # Loading/saving (atomic), notebooks, trash diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index ffa9e88..1b69b7a 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -805,6 +805,19 @@ "dest": "cargo/vendor/d3d12-0.19.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/data-url/data-url-0.3.2.crate", + "sha256": "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376", + "dest": "cargo/vendor/data-url-0.3.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376\", \"files\": {}}", + "dest": "cargo/vendor/data-url-0.3.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1156,6 +1169,19 @@ "dest": "cargo/vendor/flate2-1.1.10", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/float-cmp/float-cmp-0.9.0.crate", + "sha256": "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4", + "dest": "cargo/vendor/float-cmp-0.9.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4\", \"files\": {}}", + "dest": "cargo/vendor/float-cmp-0.9.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1884,6 +1910,19 @@ "dest": "cargo/vendor/image-webp-0.2.4", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/imagesize/imagesize-0.15.0.crate", + "sha256": "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a", + "dest": "cargo/vendor/imagesize-0.15.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a\", \"files\": {}}", + "dest": "cargo/vendor/imagesize-0.15.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2794,6 +2833,19 @@ "dest": "cargo/vendor/pic-scale-0.7.12", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/pico-args/pico-args-0.5.0.crate", + "sha256": "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315", + "dest": "cargo/vendor/pico-args-0.5.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315\", \"files\": {}}", + "dest": "cargo/vendor/pico-args-0.5.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3197,6 +3249,32 @@ "dest": "cargo/vendor/renderdoc-sys-1.1.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/resvg/resvg-0.49.0.crate", + "sha256": "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8", + "dest": "cargo/vendor/resvg-0.49.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8\", \"files\": {}}", + "dest": "cargo/vendor/resvg-0.49.0", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rgb/rgb-0.8.53.crate", + "sha256": "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4", + "dest": "cargo/vendor/rgb-0.8.53" + }, + { + "type": "inline", + "contents": "{\"package\": \"47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4\", \"files\": {}}", + "dest": "cargo/vendor/rgb-0.8.53", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3210,6 +3288,19 @@ "dest": "cargo/vendor/ring-0.17.14", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/roxmltree/roxmltree-0.21.2.crate", + "sha256": "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907", + "dest": "cargo/vendor/roxmltree-0.21.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907\", \"files\": {}}", + "dest": "cargo/vendor/roxmltree-0.21.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3483,6 +3574,19 @@ "dest": "cargo/vendor/simdutf8-0.1.5", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/simplecss/simplecss-0.2.2.crate", + "sha256": "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c", + "dest": "cargo/vendor/simplecss-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c\", \"files\": {}}", + "dest": "cargo/vendor/simplecss-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3678,6 +3782,19 @@ "dest": "cargo/vendor/subtle-2.6.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/svgtypes/svgtypes-0.16.1.crate", + "sha256": "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d", + "dest": "cargo/vendor/svgtypes-0.16.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d\", \"files\": {}}", + "dest": "cargo/vendor/svgtypes-0.16.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3873,6 +3990,19 @@ "dest": "cargo/vendor/tiny-skia-0.11.4", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.12.0.crate", + "sha256": "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea", + "dest": "cargo/vendor/tiny-skia-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea\", \"files\": {}}", + "dest": "cargo/vendor/tiny-skia-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3886,6 +4016,19 @@ "dest": "cargo/vendor/tiny-skia-path-0.11.4", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.12.0.crate", + "sha256": "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9", + "dest": "cargo/vendor/tiny-skia-path-0.12.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9\", \"files\": {}}", + "dest": "cargo/vendor/tiny-skia-path-0.12.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -4094,6 +4237,19 @@ "dest": "cargo/vendor/url-2.5.8", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/usvg/usvg-0.49.0.crate", + "sha256": "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c", + "dest": "cargo/vendor/usvg-0.49.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c\", \"files\": {}}", + "dest": "cargo/vendor/usvg-0.49.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", diff --git a/src/app.rs b/src/app.rs index ba419c9..cd2783f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2216,9 +2216,9 @@ pub fn run() -> Result<(), Box> { } } - // Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++: importieren + // Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote: importieren WindowEvent::DroppedFile(path) => { - if crate::file_picker::is_pdf_path(&path) || crate::xopp::is_xopp_path(&path) { + if crate::import::is_importable_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; @@ -3844,15 +3844,7 @@ pub fn run() -> Result<(), Box> { .file_stem() .map(|s| s.to_string_lossy().into_owned()) .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 { + match crate::import::import_file(&storage_base_dir, &title, &path) { Ok((new_nb, new_manifest, new_dir, report)) => { let id = new_manifest.id.clone(); change_library(&storage_base_dir, &mut ui_state, |library| { diff --git a/src/file_picker.rs b/src/file_picker.rs index 71d1909..3e5bbc4 100644 --- a/src/file_picker.rs +++ b/src/file_picker.rs @@ -1,4 +1,4 @@ -//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder, PDFs und Xournal++-Dateien. +//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote). //! //! 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,7 +22,7 @@ pub struct PickerEntry { pub enum PickerKind { /// Bilder zum Einfügen auf der aktuellen Seite Image, - /// PDF or Xournal++ file to import as a new notebook + /// PDF, Xournal++ or Rnote file to import as a new notebook Import, } @@ -31,7 +31,7 @@ impl PickerKind { pub fn accepts(self, path: &Path) -> bool { match self { PickerKind::Image => is_supported_image_path(path), - PickerKind::Import => is_pdf_path(path) || crate::xopp::is_xopp_path(path), + PickerKind::Import => crate::import::is_importable_path(path), } } } @@ -193,6 +193,7 @@ mod tests { "c.webp", "Skript.PDF", "mathe.xopp", + "skizze.rnote", ] { std::fs::write(dir.path().join(name), b"x").unwrap(); } @@ -213,7 +214,13 @@ mod tests { ); assert_eq!( names(PickerKind::Import), - ["aOrdner", "zOrdner", "mathe.xopp", "Skript.PDF"] + [ + "aOrdner", + "zOrdner", + "mathe.xopp", + "skizze.rnote", + "Skript.PDF" + ] ); } diff --git a/src/i18n.rs b/src/i18n.rs index 3e6c81b..fb125eb 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -167,6 +167,8 @@ mod tests { ("text.rs", include_str!("text.rs")), ("ui.rs", include_str!("ui.rs")), ("updater.rs", include_str!("updater.rs")), + ("import.rs", include_str!("import.rs")), + ("rnote.rs", include_str!("rnote.rs")), ("xopp.rs", include_str!("xopp.rs")), ]; diff --git a/src/i18n/en.rs b/src/i18n/en.rs index fa97a73..521941e 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -450,14 +450,16 @@ pub const EN: &[(&str, &str)] = &[ (", 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"), + ("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien", "No folders, PDF, Xournal++ or Rnote files"), + ("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ auch PDF-Hintergründe.", "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) and Rnote (.rnote): strokes, shapes, texts and images are taken over, for Xournal++ also PDF backgrounds."), + ("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++ and Rnote as a new notebook and write on them"), + ("📄 Importieren (PDF, Xournal++, Rnote) …", "📄 Import (PDF, Xournal++, Rnote) …"), + ("Keine Rnote-Datei", "Not an Rnote file"), + ("Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern", "Rnote file too old ({version}): please open it in a current Rnote and save it again"), ]; diff --git a/src/import.rs b/src/import.rs new file mode 100644 index 0000000..742c1fa --- /dev/null +++ b/src/import.rs @@ -0,0 +1,257 @@ +//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++ +//! (`xopp`) and Rnote (`rnote`). This module holds what the importers share: building +//! strokes, texts and images in the stored format and creating the notebook. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use crate::document::{Notebook, Page, PageBackground, StrokeTool}; +use crate::storage::{self, NotebookLayout, NotebookManifest, SampleData, StrokeData}; +use crate::t; +use crate::text::{TextBox, TextFont}; + +/// Largest accepted file after decompression (guards against gzip bombs). +const MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024; + +/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters). +pub const HIGHLIGHTER_ALPHA: f32 = 0.35; + +/// 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, +} + +/// Result of an importer: pages, the next free stroke id, the layout (`None`: default). +pub struct Imported { + pub pages: Vec, + pub next_stroke_id: u64, + pub layout: Option, + pub report: ImportReport, +} + +/// Can the file be imported (by its name)? +pub fn is_importable_path(path: &Path) -> bool { + crate::file_picker::is_pdf_path(path) + || crate::xopp::is_xopp_path(path) + || crate::rnote::is_rnote_path(path) +} + +/// Imports a PDF, Xournal++ or Rnote file as a new notebook titled `title`. +pub fn import_file( + base_dir: &Path, + title: &str, + path: &Path, +) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> { + if crate::xopp::is_xopp_path(path) { + crate::xopp::create_notebook_from_xopp(base_dir, title, path) + } else if crate::rnote::is_rnote_path(path) { + crate::rnote::create_notebook_from_rnote(base_dir, title, path) + } else { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + storage::create_notebook_from_pdf(base_dir, title, bytes) + .map(|(nb, manifest, dir)| (nb, manifest, dir, ImportReport::default())) + } +} + +/// Decompresses gzip data; other data is returned unchanged. +pub fn gunzip(bytes: &[u8]) -> Result, String> { + if !bytes.starts_with(&[0x1f, 0x8b]) { + return Ok(bytes.to_vec()); + } + let mut data = Vec::new(); + flate2::read::MultiGzDecoder::new(bytes) + .take(MAX_UNCOMPRESSED_BYTES) + .read_to_end(&mut data) + .map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?; + Ok(data) +} + +/// Creates a new notebook and fills it with the pages `build` creates in its folder (images +/// and PDFs go to its asset folder). On failure nothing is left behind. +pub fn create_notebook( + base_dir: &Path, + title: &str, + build: impl FnOnce(&Path) -> Result, +) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> { + let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?; + let result = (|| { + let imported = build(&dir)?; + let page_count = imported.pages.len() as u64; + let notebook = + Notebook::from_pages(imported.pages, 0, imported.next_stroke_id, page_count + 1); + let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?; + if imported.layout.is_some() { + manifest.layout = imported.layout; + } + storage::save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?; + Ok((notebook, manifest, imported.report)) + })(); + match result { + Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)), + Err(err) => { + let _ = std::fs::remove_dir_all(&dir); + Err(err) + } + } +} + +/// Builds a page from stored-format strokes. +pub fn page( + id: u64, + size: glam::Vec2, + background: PageBackground, + pdf_background: Option, + strokes: Vec, +) -> Page { + storage::PageData { + format_version: storage::CURRENT_FORMAT_VERSION, + id, + background, + pdf_background, + title: None, + size: (size != crate::document::A4_SIZE).then(|| size.to_array()), + strokes, + } + .to_page() +} + +/// Our stroke width and the sample pressures for absolute widths per point (page units): +/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner +/// ones less (down to a third of the widest). +pub fn fit_widths(widths: &[f32]) -> (f32, Vec) { + let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01); + let width = widest / 1.5; + let pressures = widths + .iter() + .map(|w| (w / width - 0.5).clamp(0.0, 1.0)) + .collect(); + (width, pressures) +} + +/// Pen or highlighter stroke through `points` (page units) with the given widths per point. +pub fn ink_stroke( + id: u64, + mut color: [f32; 4], + highlighter: bool, + points: &[glam::Vec2], + width: f32, + pressures: &[f32], +) -> StrokeData { + 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))| SampleData { + x: p.x, + y: p.y, + p: pressure, + t: i as f64 * 0.005, + }) + .collect(); + StrokeData { + id, + color, + samples, + shape: None, + tool, + width, + finalized: true, + image: None, + text: None, + } +} + +/// Text field with its upper left corner at `origin` and the axes (page vector per unit to +/// the right and down). Without `wrap_width` it is as wide as its longest line. +#[allow(clippy::too_many_arguments)] +pub fn text_stroke( + id: u64, + content: &str, + size: f32, + font: TextFont, + origin: glam::Vec2, + axes: [glam::Vec2; 2], + wrap_width: Option, + mut color: [f32; 4], +) -> StrokeData { + let wrap_width = wrap_width.unwrap_or_else(|| { + let unwrapped = TextBox::new(content.to_string(), size, font, f32::MAX / 4.0); + let widest = unwrapped + .layout() + .lines + .iter() + .map(|line| line.width) + .fold(0.0, f32::max); + widest + size + }); + let text = TextBox::new(content.to_string(), size, font, wrap_width); + let corners = crate::text::frame_corners(origin, axes, text.box_size()); + color[3] = 1.0; + StrokeData { + id, + color, + samples: corner_samples(corners), + shape: None, + tool: StrokeTool::Pen, + width: 0.0, + finalized: true, + image: None, + text: Some(text), + } +} + +/// Image with the corners upper left, upper right, lower right, lower left. +pub fn image_stroke(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, + } +} + +fn corner_samples(corners: [glam::Vec2; 4]) -> Vec { + corners + .iter() + .map(|c| SampleData { + x: c.x, + y: c.y, + p: 1.0, + t: 0.0, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fit_widths_keeps_the_drawn_widths() { + let (width, pressures) = fit_widths(&[30.0, 15.0, 10.0, 2.0]); + let drawn: Vec = pressures.iter().map(|p| width * (0.5 + p)).collect(); + assert!((drawn[0] - 30.0).abs() < 1e-3); + assert!((drawn[1] - 15.0).abs() < 1e-3); + assert!((drawn[2] - 10.0).abs() < 1e-3); + // Thinner than a third of the widest point is not possible + assert!((drawn[3] - 10.0).abs() < 1e-3); + } +} diff --git a/src/lib.rs b/src/lib.rs index 98dfc0d..c021f3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,12 +13,14 @@ pub mod geometry; pub mod history; pub mod i18n; pub mod image_layer; +pub mod import; pub mod library; pub mod osk; pub mod page_export; pub mod pdf_doc; pub mod pdf_merge; pub mod platform; +pub mod rnote; pub mod ruler; pub mod selection; pub mod settings; diff --git a/src/rnote.rs b/src/rnote.rs new file mode 100644 index 0000000..589d7be --- /dev/null +++ b/src/rnote.rs @@ -0,0 +1,938 @@ +//! Import of Rnote notebooks (.rnote) (Issue #47). +//! +//! An .rnote file is gzip-compressed JSON: `{"version": "0.x.y", "data": {"engine_snapshot": +//! {...}}}`. The snapshot is Rnote's internal state without a specification, so it is read +//! leniently as JSON and only what we need is picked out; files from Rnote 0.5.10 on are +//! supported (the layout of older ones differs too much). Coordinates are pixels at the +//! document's dpi. +//! +//! Pages: fixed-size and continuous documents become one page per Rnote page (a stroke goes +//! to the page its middle lies on), infinite documents an endless notebook. + +use base64::Engine; +use glam::DVec2; +use serde_json::Value; +use std::path::Path; + +use crate::document::{Notebook, PageBackground}; +use crate::import::{self, ImportReport, Imported}; +use crate::storage::{NotebookLayout, NotebookManifest, StrokeData}; +use crate::t; +use crate::text::TextFont; + +/// At most this many pages are created (protects against nonsensical document sizes). +const MAX_PAGES: usize = 2000; + +/// Is the file by its name an Rnote notebook? +pub fn is_rnote_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("rnote")) +} + +/// Parsed Rnote document, still in Rnote pixels. +#[derive(Debug, Clone, PartialEq)] +pub struct RnoteDocument { + /// Page size in pixels and dpi + pub format: DVec2, + pub dpi: f64, + /// Document bounds (upper left corner and size) + pub origin: DVec2, + pub size: DVec2, + pub endless: bool, + pub background: PageBackground, + /// In drawing order + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RnoteItem { + Ink { + color: [f32; 4], + highlighter: bool, + points: Vec, + /// Drawn width per point + widths: Vec, + }, + Text { + content: String, + size: f64, + mono: bool, + /// Upper left corner and axes + affine: [DVec2; 3], + max_width: Option, + color: [f32; 4], + }, + Bitmap { + width: u32, + height: u32, + /// Straight (not premultiplied) RGBA + pixels: Vec, + corners: [DVec2; 4], + }, + /// SVG image (Rnote stores imported PDF pages like this), rasterized on import + Svg { svg: String, corners: [DVec2; 4] }, + /// Element without a counterpart or unreadable + Unsupported, +} + +impl RnoteItem { + /// Point that decides which page the element belongs to. + fn anchor(&self) -> Option { + match self { + RnoteItem::Ink { points, .. } => { + let (min, max) = points.iter().fold( + (DVec2::splat(f64::MAX), DVec2::splat(f64::MIN)), + |(a, b), &p| (a.min(p), b.max(p)), + ); + (!points.is_empty()).then(|| (min + max) / 2.0) + } + RnoteItem::Text { affine, .. } => Some(affine[2]), + RnoteItem::Bitmap { corners, .. } | RnoteItem::Svg { corners, .. } => { + Some((corners[0] + corners[2]) / 2.0) + } + RnoteItem::Unsupported => None, + } + } +} + +fn num(value: &Value, key: &str) -> Option { + value.get(key)?.as_f64().filter(|v| v.is_finite()) +} + +/// Vectors are stored as `[x, y]` (older versions possibly as `{"x": .., "y": ..}`). +fn vec2(value: &Value) -> Option { + match value { + Value::Array(a) if a.len() == 2 => Some(DVec2::new(a[0].as_f64()?, a[1].as_f64()?)), + Value::Object(_) => Some(DVec2::new(num(value, "x")?, num(value, "y")?)), + _ => None, + } + .filter(|v| v.is_finite()) +} + +/// Affine transformation as x axis, y axis and translation. Since Rnote 0.15 `affine` holds +/// the six values; before, `transform.affine` held a column-major 3×3 matrix. +fn affine(value: &Value) -> Option<[DVec2; 3]> { + let values = value + .get("affine") + .or_else(|| value.get("transform")?.get("affine"))? + .as_array()?; + let v: Vec = values.iter().filter_map(Value::as_f64).collect(); + let [a, b, c, d, e, f] = match v.len() { + 6 => [v[0], v[1], v[2], v[3], v[4], v[5]], + 9 => [v[0], v[1], v[3], v[4], v[6], v[7]], + _ => return None, + }; + Some([DVec2::new(a, b), DVec2::new(c, d), DVec2::new(e, f)]) +} + +fn apply(affine: &[DVec2; 3], p: DVec2) -> DVec2 { + affine[0] * p.x + affine[1] * p.y + affine[2] +} + +fn color(value: Option<&Value>) -> Option<[f32; 4]> { + let value = value.filter(|v| v.is_object())?; + let c = |k| num(value, k).unwrap_or(0.0).clamp(0.0, 1.0) as f32; + Some([ + c("r"), + c("g"), + c("b"), + num(value, "a").map_or(1.0, |a| a.clamp(0.0, 1.0) as f32), + ]) +} + +/// The one variant of an externally tagged enum: `{"name": {...}}`. +fn variant(value: &Value) -> Option<(&str, &Value)> { + let object = value.as_object()?; + let (name, inner) = object.iter().next()?; + Some((name.as_str(), inner)) +} + +/// Width for the pressure according to Rnote's pressure curve. +fn curve_width(curve: &str, width: f64, pressure: f64) -> f64 { + let p = pressure.clamp(0.0, 1.0); + match curve { + "const" => width, + "sqrt" => width * p.sqrt(), + "cbrt" => width * p.cbrt(), + "pow2" => width * p.powi(2), + "pow3" => width * p.powi(3), + _ => width * p, + } +} + +/// Style options (smooth, rough or textured). +struct Style { + width: f64, + stroke_color: Option<[f32; 4]>, + fill_color: Option<[f32; 4]>, + pressure_curve: String, +} + +fn style(value: Option<&Value>) -> Option