feat: import Rnote notebooks (Issue #47)

The import (menu entry and drag & drop) now also accepts .rnote files
from Rnote 0.5.10 on, including the transformation and document config
layouts that changed in 0.13 and 0.15. Taken over: brush strokes with
their pressure curve, shapes as outlines, texts, bitmap images and SVG
images, which are rasterized with resvg (Rnote stores imported PDF
pages as SVG). Fixed-size and continuous documents keep their pages;
infinite ones become an endless notebook.

The parts shared with the Xournal++ import moved to src/import.rs.
resvg and its dependencies were added to the Flatpak cargo sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-27 11:40:51 +01:00
1 parent 2bd697ef82
commit 46026eb8e5
14 files changed
+1567 -187

No files matched your search

Generated
+130 -2
View File
@@ -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"
+2
View File
@@ -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"
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+156
View File
@@ -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",
+3 -11
View File
@@ -2216,9 +2216,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
}
// 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<dyn std::error::Error>> {
.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| {
+11 -4
View File
@@ -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"
]
);
}
+2
View File
@@ -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")),
];
+6 -4
View File
@@ -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"),
];
+257
View File
@@ -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<String>,
}
/// Result of an importer: pages, the next free stroke id, the layout (`None`: default).
pub struct Imported {
pub pages: Vec<Page>,
pub next_stroke_id: u64,
pub layout: Option<NotebookLayout>,
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<Vec<u8>, 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<Imported, String>,
) -> 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, &notebook, &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<crate::document::PdfBackground>,
strokes: Vec<StrokeData>,
) -> 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<f32>) {
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<f32>,
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<SampleData> {
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<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);
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);
}
}
+2
View File
@@ -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;
+938
View File
@@ -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<RnoteItem>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RnoteItem {
Ink {
color: [f32; 4],
highlighter: bool,
points: Vec<DVec2>,
/// Drawn width per point
widths: Vec<f64>,
},
Text {
content: String,
size: f64,
mono: bool,
/// Upper left corner and axes
affine: [DVec2; 3],
max_width: Option<f64>,
color: [f32; 4],
},
Bitmap {
width: u32,
height: u32,
/// Straight (not premultiplied) RGBA
pixels: Vec<u8>,
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<DVec2> {
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<f64> {
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<DVec2> {
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<f64> = 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<Style> {
let (kind, options) = variant(value?)?;
let curve = if kind == "rough" {
"const".to_string()
} else {
options
.get("pressure_curve")
.and_then(Value::as_str)
.unwrap_or("linear")
.to_string()
};
Some(Style {
width: num(options, "stroke_width").unwrap_or(1.0).max(0.1),
stroke_color: color(options.get("stroke_color")),
fill_color: color(options.get("fill_color")),
pressure_curve: curve,
})
}
/// Points along a Bézier curve (without its start), at most every 2 pixels; for a line
/// just its end.
fn bezier(start: DVec2, controls: &[DVec2], end: DVec2) -> Vec<DVec2> {
if controls.is_empty() {
return vec![end];
}
let length = controls
.iter()
.chain([&end])
.fold((0.0, start), |(sum, prev), &p| (sum + prev.distance(p), p))
.0;
let steps = ((length / 2.0).ceil() as usize).clamp(1, 32);
(1..=steps)
.map(|i| {
let t = i as f64 / steps as f64;
let u = 1.0 - t;
match controls {
[cp] => start * u * u + *cp * 2.0 * u * t + end * t * t,
[cp1, cp2] => {
start * u * u * u
+ *cp1 * 3.0 * u * u * t
+ *cp2 * 3.0 * u * t * t
+ end * t * t * t
}
_ => start + (end - start) * t,
}
})
.collect()
}
fn element(value: &Value) -> Option<(DVec2, f64)> {
Some((
vec2(value.get("pos")?)?,
num(value, "pressure").unwrap_or(0.5),
))
}
fn brushstroke(value: &Value, highlighter: bool) -> Option<RnoteItem> {
let style = style(value.get("style"))?;
let color = style.stroke_color?;
let path = value.get("path")?;
let (mut prev, mut prev_pressure) = element(path.get("start")?)?;
let mut points = vec![prev];
let mut pressures = vec![prev_pressure];
for segment in path.get("segments")?.as_array()? {
let Some((kind, seg)) = variant(segment) else {
continue;
};
let Some((end, end_pressure)) = seg.get("end").and_then(element) else {
continue;
};
let controls: Vec<DVec2> = match kind {
"quadbezto" | "quadbez" => seg.get("cp").and_then(vec2).into_iter().collect(),
"cubbezto" | "cubbez" => [seg.get("cp1"), seg.get("cp2")]
.into_iter()
.filter_map(|v| v.and_then(vec2))
.collect(),
_ => Vec::new(),
};
let new = bezier(prev, &controls, end);
let n = new.len() as f64;
for (i, p) in new.into_iter().enumerate() {
let t = (i + 1) as f64 / n;
points.push(p);
pressures.push(prev_pressure + (end_pressure - prev_pressure) * t);
}
prev = end;
prev_pressure = end_pressure;
}
let widths = pressures
.iter()
.map(|&p| curve_width(&style.pressure_curve, style.width, p))
.collect();
Some(RnoteItem::Ink {
highlighter: highlighter || color[3] < 0.99,
color,
points,
widths,
})
}
/// Outline of a shape as polylines.
fn shape_lines(shape: &Value) -> Option<Vec<Vec<DVec2>>> {
let (kind, s) = variant(shape)?;
let v = |k: &str| s.get(k).and_then(vec2);
let lines = match kind {
"line" => vec![vec![v("start")?, v("end")?]],
"arrow" => {
let (start, tip) = (v("start")?, v("tip")?);
let back = (start - tip).normalize_or_zero();
let length = (start.distance(tip) * 0.4).min(20.0);
let wing = |angle: f64| tip + DVec2::from_angle(angle).rotate(back) * length;
vec![vec![start, tip], vec![wing(0.5), tip, wing(-0.5)]]
}
"rect" => {
let half = vec2(s.get("cuboid")?.get("half_extents")?)?;
let affine = affine(s)?;
let corners = [
(-1.0, -1.0),
(1.0, -1.0),
(1.0, 1.0),
(-1.0, 1.0),
(-1.0, -1.0),
];
vec![corners
.iter()
.map(|&(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y)))
.collect()]
}
"ellipse" => {
let radii = v("radii")?;
let affine = affine(s)?;
vec![(0..=64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
apply(&affine, DVec2::new(a.cos() * radii.x, a.sin() * radii.y))
})
.collect()]
}
"quadbez" | "cubbez" => {
let start = v("start")?;
let controls: Vec<DVec2> = ["cp", "cp1", "cp2"].iter().filter_map(|k| v(k)).collect();
let mut points = vec![start];
points.extend(bezier(start, &controls, v("end")?));
vec![points]
}
"polyline" | "polygon" => {
let start = v("start")?;
let mut points = vec![start];
points.extend(s.get("path")?.as_array()?.iter().filter_map(vec2));
if kind == "polygon" {
points.push(start);
}
vec![points]
}
_ => return None,
};
Some(lines)
}
fn shapestroke(value: &Value, highlighter: bool) -> Vec<RnoteItem> {
let Some(Style {
width,
stroke_color,
fill_color,
..
}) = style(value.get("style"))
else {
return vec![RnoteItem::Unsupported];
};
let (Some(color), Some(lines)) = (
stroke_color.or(fill_color),
value.get("shape").and_then(shape_lines),
) else {
return vec![RnoteItem::Unsupported];
};
lines
.into_iter()
.map(|points| RnoteItem::Ink {
color,
highlighter: highlighter || color[3] < 0.99,
widths: vec![width; points.len()],
points,
})
.collect()
}
fn textstroke(value: &Value) -> Option<RnoteItem> {
let style = value.get("text_style")?;
Some(RnoteItem::Text {
content: value.get("text")?.as_str()?.to_string(),
size: num(style, "font_size").unwrap_or(32.0).max(1.0),
mono: style
.get("font_family")
.and_then(Value::as_str)
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
affine: affine(value)?,
max_width: num(style, "max_width").filter(|w| *w > 0.0),
color: color(style.get("color")).unwrap_or([0.0, 0.0, 0.0, 1.0]),
})
}
fn bitmapimage(value: &Value) -> Option<RnoteItem> {
let image = value.get("image")?;
let width = num(image, "pixel_width")? as u32;
let height = num(image, "pixel_height")? as u32;
let mut pixels = base64::engine::general_purpose::STANDARD
.decode(image.get("data")?.as_str()?)
.ok()?;
if pixels.len() != width as usize * height as usize * 4 {
return None;
}
unpremultiply(&mut pixels);
Some(RnoteItem::Bitmap {
width,
height,
pixels,
corners: rectangle_corners(value.get("rectangle")?)?,
})
}
fn vectorimage(value: &Value) -> Option<RnoteItem> {
Some(RnoteItem::Svg {
svg: value.get("svg_data")?.as_str()?.to_string(),
corners: rectangle_corners(value.get("rectangle")?)?,
})
}
/// Corners (upper left, upper right, lower right, lower left) of an Rnote rectangle: a
/// box around the origin, placed by its affine transformation.
fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
let half = vec2(rectangle.get("cuboid")?.get("half_extents")?)?;
let affine = affine(rectangle)?;
Some(
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.map(|(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y))),
)
}
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
fn unpremultiply(pixels: &mut [u8]) {
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
use resvg::{tiny_skia, usvg};
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
let px_per_unit = 250.0 / 254.0;
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
let transform = tiny_skia::Transform::from_scale(
width as f32 / tree.size().width(),
height as f32 / tree.size().height(),
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut pixels = pixmap.take();
unpremultiply(&mut pixels);
Some((width, height, pixels))
}
/// Rnote's version as (major, minor, patch).
fn version(text: &str) -> Option<(u64, u64, u64)> {
let v = semver::Version::parse(text).ok()?;
Some((v.major, v.minor, v.patch))
}
/// Parses the (decompressed) JSON of an .rnote file.
pub fn parse(json: &[u8]) -> Result<RnoteDocument, String> {
let not_rnote = || t!("Keine Rnote-Datei").to_string();
let wrapper: Value = serde_json::from_slice(json).map_err(|_| not_rnote())?;
let version_text = wrapper
.get("version")
.and_then(Value::as_str)
.ok_or_else(not_rnote)?;
if version(version_text).is_none_or(|v| v < (0, 5, 10)) {
return Err(t!(
"Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern",
version = version_text
)
.to_string());
}
let snapshot = wrapper
.get("data")
.and_then(|d| d.get("engine_snapshot"))
.ok_or_else(not_rnote)?;
let document = snapshot.get("document").ok_or_else(not_rnote)?;
// Since Rnote 0.13 format, background and layout are in `config`
let config = document.get("config").unwrap_or(document);
let format = config.get("format").unwrap_or(&Value::Null);
let dpi = num(format, "dpi").filter(|d| *d > 0.0).unwrap_or(96.0);
let format_size = DVec2::new(
num(format, "width").unwrap_or(dpi * 210.0 / 25.4).max(1.0),
num(format, "height").unwrap_or(dpi * 297.0 / 25.4).max(1.0),
);
let background = match config
.get("background")
.and_then(|b| b.get("pattern"))
.and_then(Value::as_str)
{
Some("lines") => PageBackground::Lined,
Some("grid" | "isometric_grid") => PageBackground::Grid5mm,
Some("dots" | "isometric_dots") => PageBackground::DotGrid,
_ => PageBackground::Blank,
};
let endless = matches!(
config.get("layout").and_then(Value::as_str),
Some("infinite" | "semi_infinite")
);
let strokes = snapshot
.get("stroke_components")
.and_then(Value::as_array)
.ok_or_else(not_rnote)?;
let chrono = snapshot
.get("chrono_components")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
// Slot maps are stored as slot arrays; the chrono components share the slot index
let mut ordered: Vec<(u64, Vec<RnoteItem>)> = Vec::new();
for (index, slot) in strokes.iter().enumerate() {
let Some(stroke) = slot.get("value").filter(|v| !v.is_null()) else {
continue;
};
let chrono = chrono.get(index).and_then(|c| c.get("value"));
let t = chrono.and_then(|c| c.get("t")).and_then(Value::as_u64);
let highlighter = chrono
.and_then(|c| c.get("layer"))
.and_then(Value::as_str)
.is_some_and(|l| l.eq_ignore_ascii_case("highlighter"));
let items = match variant(stroke) {
Some(("brushstroke", v)) => {
vec![brushstroke(v, highlighter).unwrap_or(RnoteItem::Unsupported)]
}
Some(("shapestroke", v)) => shapestroke(v, highlighter),
Some(("textstroke", v)) => vec![textstroke(v).unwrap_or(RnoteItem::Unsupported)],
Some(("bitmapimage", v)) => vec![bitmapimage(v).unwrap_or(RnoteItem::Unsupported)],
Some(("vectorimage", v)) => vec![vectorimage(v).unwrap_or(RnoteItem::Unsupported)],
_ => vec![RnoteItem::Unsupported],
};
ordered.push((t.unwrap_or(index as u64), items));
}
ordered.sort_by_key(|(t, _)| *t);
Ok(RnoteDocument {
format: format_size,
dpi,
origin: DVec2::new(
num(document, "x").unwrap_or(0.0),
num(document, "y").unwrap_or(0.0),
),
size: DVec2::new(
num(document, "width").unwrap_or(format_size.x),
num(document, "height").unwrap_or(format_size.y),
),
endless,
background,
items: ordered.into_iter().flat_map(|(_, items)| items).collect(),
})
}
/// Creates a new notebook from an Rnote file.
pub fn create_notebook_from_rnote(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, std::path::PathBuf, ImportReport), String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let doc = parse(&import::gunzip(&bytes)?)?;
import::create_notebook(base_dir, title, |dir| Ok(convert(&doc, dir)))
}
/// A target page: where its Rnote page starts, and how Rnote pixels map onto it.
struct Target {
rnote_origin: DVec2,
origin: glam::Vec2,
scale: f32,
size: glam::Vec2,
strokes: Vec<StrokeData>,
}
impl Target {
fn map(&self, p: DVec2) -> glam::Vec2 {
self.origin + (p - self.rnote_origin).as_vec2() * self.scale
}
}
fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
// Page units per Rnote pixel at the document's dpi
let units_per_px = (crate::document::UNITS_PER_MM as f64 * 25.4 / doc.dpi) as f32;
let (columns, mut targets) = if doc.endless {
let target = Target {
rnote_origin: DVec2::ZERO,
origin: crate::document::ENDLESS_HOME,
scale: units_per_px,
size: crate::document::ENDLESS_PAGE_SIZE,
strokes: Vec::new(),
};
(1, vec![target])
} else {
// 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_pt = (doc.format / doc.dpi * 72.0).as_vec2();
let size = crate::document::pdf_page_size(size_pt.x, size_pt.y);
let [origin, top_right, _, bottom_left] =
crate::document::pdf_placement(size_pt.x, size_pt.y, size);
let scale_pt =
((top_right.x - origin.x) / size_pt.x).min((bottom_left.y - origin.y) / size_pt.y);
let scale = scale_pt * (72.0 / doc.dpi) as f32;
// Rnote grows documents page by page; tolerate rounding in the stored size
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
let columns = count(doc.size.x, doc.format.x);
let rows = count(doc.size.y, doc.format.y);
let rows = rows.min((MAX_PAGES / columns).max(1));
let targets = (0..rows * columns)
.map(|i| Target {
rnote_origin: doc.origin
+ DVec2::new((i % columns) as f64, (i / columns) as f64) * doc.format,
origin,
scale,
size,
strokes: Vec::new(),
})
.collect();
(columns, targets)
};
let rows = targets.len() / columns;
let mut next_id: u64 = 1;
for item in &doc.items {
let Some(anchor) = item.anchor() else {
report.skipped += 1;
continue;
};
let cell = ((anchor - doc.origin) / doc.format).floor();
let index = if doc.endless {
0
} else {
let column = (cell.x.max(0.0) as usize).min(columns - 1);
let row = (cell.y.max(0.0) as usize).min(rows - 1);
row * columns + column
};
let target = &mut targets[index];
let id = next_id;
next_id += 1;
let stroke = match item {
RnoteItem::Ink {
color,
highlighter,
points,
widths,
} => {
let points: Vec<glam::Vec2> = points.iter().map(|&p| target.map(p)).collect();
let widths: Vec<f32> = widths.iter().map(|&w| w as f32 * target.scale).collect();
let (width, pressures) = import::fit_widths(&widths);
import::ink_stroke(id, *color, *highlighter, &points, width, &pressures)
}
RnoteItem::Text {
content,
size,
mono,
affine,
max_width,
color,
} => {
let font = if *mono {
TextFont::Mono
} else {
TextFont::Sans
};
import::text_stroke(
id,
content,
*size as f32 * target.scale,
font,
target.map(affine[2]),
[affine[0].as_vec2(), affine[1].as_vec2()],
max_width.map(|w| w as f32 * target.scale),
*color,
)
}
RnoteItem::Bitmap {
width,
height,
pixels,
corners,
} => match assets.import_rgba(*width, *height, pixels.clone()) {
Ok(image) => import::image_stroke(id, image.asset, corners.map(|c| target.map(c))),
Err(_) => {
report.skipped += 1;
continue;
}
},
RnoteItem::Svg { svg, corners } => {
let corners = corners.map(|c| target.map(c));
let size = glam::Vec2::new(
corners[0].distance(corners[1]),
corners[0].distance(corners[3]),
);
let image = rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
assets.import_rgba(width, height, pixels).ok()
});
match image {
Some(image) => import::image_stroke(id, image.asset, corners),
None => {
report.skipped += 1;
continue;
}
}
}
RnoteItem::Unsupported => unreachable!("has no anchor"),
};
target.strokes.push(stroke);
}
let pages = targets
.into_iter()
.enumerate()
.map(|(i, target)| {
import::page(
i as u64 + 1,
target.size,
doc.background,
None,
target.strokes,
)
})
.collect();
Imported {
pages,
next_stroke_id: next_id,
layout: doc.endless.then_some(NotebookLayout::Endless),
report,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StrokeTool;
use serde_json::json;
use std::io::Write;
fn slot(value: Value) -> Value {
json!({ "value": value, "version": 1 })
}
/// A small Rnote 0.15 document: two A4 pages at 96 dpi.
fn sample(layout: &str) -> Value {
let brush = json!({ "brushstroke": {
"path": {
"start": { "pos": [100.0, 100.0], "pressure": 0.5 },
"segments": [
{ "lineto": { "end": { "pos": [120.0, 100.0], "pressure": 1.0 } } },
{ "cubbezto": { "cp1": [130.0, 110.0], "cp2": [140.0, 110.0],
"end": { "pos": [150.0, 100.0], "pressure": 0.25 } } }
]
},
"style": { "smooth": { "stroke_width": 4.0,
"stroke_color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 },
"fill_color": null, "pressure_curve": "linear" } }
}});
let rect = json!({ "shapestroke": {
"shape": { "rect": { "cuboid": { "half_extents": [50.0, 20.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 300.0, 1300.0] } },
"style": { "rough": { "stroke_width": 2.0,
"stroke_color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } } }
}});
let text = json!({ "textstroke": {
"text": "Hallo",
"affine": [1.0, 0.0, 0.0, 1.0, 50.0, 60.0],
"text_style": { "font_family": "serif", "font_size": 32.0, "max_width": null,
"color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } }
}});
let pixels = base64::engine::general_purpose::STANDARD.encode([128u8, 0, 0, 128].repeat(4));
let bitmap = json!({ "bitmapimage": {
"image": { "data": pixels, "pixel_width": 2, "pixel_height": 2,
"memory_format": "R8g8b8a8Premultiplied" },
"rectangle": { "cuboid": { "half_extents": [10.0, 10.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 400.0] }
}});
let svg = json!({ "vectorimage": {
"svg_data": "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10' fill='green'/></svg>",
"intrinsic_size": [10.0, 10.0],
"rectangle": { "cuboid": { "half_extents": [40.0, 40.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 1500.0] }
}});
let marker = json!({ "brushstroke": {
"path": { "start": { "pos": [10.0, 10.0], "pressure": 1.0 },
"segments": [ { "lineto": { "end": { "pos": [90.0, 10.0], "pressure": 1.0 } } } ] },
"style": { "smooth": { "stroke_width": 12.0,
"stroke_color": { "r": 1.0, "g": 1.0, "b": 0.0, "a": 1.0 },
"fill_color": null, "pressure_curve": "const" } }
}});
let chrono = |t: u32, layer: &str| slot(json!({ "t": t, "layer": layer }));
json!({
"version": "0.15.0",
"data": { "engine_snapshot": {
"document": {
"config": {
"format": { "width": 793.701, "height": 1122.52, "dpi": 96.0 },
"background": { "pattern": "grid" },
"layout": layout
},
"x": 0.0, "y": 0.0, "width": 793.701, "height": 2245.04
},
"camera": {},
// Slot 0 is the slot map's unused sentinel; the text was drawn first
"stroke_components": [
{ "value": null, "version": 0 },
slot(brush), slot(rect), slot(text), slot(bitmap), slot(svg), slot(marker)
],
"chrono_components": [
{ "value": null, "version": 0 },
chrono(2, "user_layer"), chrono(3, "user_layer"), chrono(1, "user_layer"),
chrono(4, "image"), chrono(5, "image"), chrono(6, "highlighter")
],
"chrono_counter": 7
}}
})
}
fn write_rnote(dir: &Path, value: &Value) -> std::path::PathBuf {
let path = dir.join("Skizze.rnote");
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(value.to_string().as_bytes()).unwrap();
std::fs::write(&path, encoder.finish().unwrap()).unwrap();
path
}
#[test]
fn test_parse_rnote() {
let doc = parse(sample("fixed_size").to_string().as_bytes()).unwrap();
assert_eq!(doc.background, PageBackground::Grid5mm);
assert!(!doc.endless);
// In drawing order: the text was drawn first
assert!(matches!(doc.items[0], RnoteItem::Text { .. }));
match &doc.items[1] {
RnoteItem::Ink {
points,
widths,
color,
highlighter,
} => {
assert!(!highlighter);
assert_eq!(*color, [0.0, 0.0, 1.0, 1.0]);
assert_eq!(points.first(), Some(&DVec2::new(100.0, 100.0)));
assert_eq!(points.last(), Some(&DVec2::new(150.0, 100.0)));
assert!(points.len() > 3, "the curve is sampled");
assert_eq!(widths[0], 2.0);
assert_eq!(widths[1], 4.0);
assert_eq!(*widths.last().unwrap(), 1.0);
}
other => panic!("expected ink, got {other:?}"),
}
match &doc.items[2] {
RnoteItem::Ink { points, .. } => {
assert_eq!(points.len(), 5);
assert_eq!(points[0], DVec2::new(250.0, 1280.0));
assert_eq!(points[2], DVec2::new(350.0, 1320.0));
}
other => panic!("expected the rectangle, got {other:?}"),
}
match &doc.items[3] {
RnoteItem::Bitmap { pixels, .. } => assert_eq!(&pixels[..4], &[255, 0, 0, 128]),
other => panic!("expected a bitmap, got {other:?}"),
}
assert!(matches!(doc.items[4], RnoteItem::Svg { .. }));
assert!(matches!(
doc.items[5],
RnoteItem::Ink {
highlighter: true,
..
}
));
}
#[test]
fn test_older_formats() {
// Before 0.13: format, background and layout directly in the document; before 0.15:
// transformations as column-major 3×3 matrix
let mut value = sample("continuous_vertical");
value["version"] = json!("0.9.4");
let document = &mut value["data"]["engine_snapshot"]["document"];
let config = document["config"].take();
for key in ["format", "background", "layout"] {
document[key] = config[key].clone();
}
document.as_object_mut().unwrap().remove("config");
let text =
&mut value["data"]["engine_snapshot"]["stroke_components"][3]["value"]["textstroke"];
text.as_object_mut().unwrap().remove("affine");
text["transform"] = json!({ "affine": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 1.0] });
let doc = parse(value.to_string().as_bytes()).unwrap();
assert_eq!(doc.background, PageBackground::Grid5mm);
match &doc.items[0] {
RnoteItem::Text { affine, .. } => assert_eq!(affine[2], DVec2::new(50.0, 60.0)),
other => panic!("expected a text, got {other:?}"),
}
value["version"] = json!("0.5.5");
assert!(parse(value.to_string().as_bytes()).is_err());
assert!(parse(b"{\"a\": 1}").is_err());
}
#[test]
fn test_create_notebook_from_rnote() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = write_rnote(source_dir.path(), &sample("fixed_size"));
let (notebook, manifest, dir, report) =
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
assert_eq!(report, ImportReport::default());
assert_eq!(manifest.layout, NotebookManifest::new("x", "x").layout);
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
assert_eq!(notebook.pages.len(), 2);
let [first, second] = &reloaded.pages[..] else {
panic!("two pages expected");
};
assert_eq!(first.background, PageBackground::Grid5mm);
// Text, pen, bitmap, highlighter on page 1; rectangle and SVG on page 2
assert_eq!(first.strokes.len(), 4);
assert_eq!(second.strokes.len(), 2);
assert!(first.strokes[0].is_text());
let pen = &first.strokes[1];
assert_eq!(pen.tool, StrokeTool::Pen);
// 96 dpi: 100 px = 26.46 mm
assert!((pen.points[0].position.x - 264.6).abs() < 0.5);
assert!(first.strokes[2].is_image());
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
// The rectangle sits on page 2 in page coordinates: 250 px - 0 / 1280 px - 1122.52 px
let rect = &second.strokes[0];
assert!((rect.points[0].position.x - 661.5).abs() < 0.5);
assert!((rect.points[0].position.y - 416.7).abs() < 0.5);
assert!(second.strokes[1].is_image());
}
#[test]
fn test_infinite_documents_become_endless() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = write_rnote(source_dir.path(), &sample("infinite"));
let (notebook, manifest, _, _) =
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
assert_eq!(manifest.layout, Some(NotebookLayout::Endless));
assert_eq!(notebook.pages.len(), 1);
assert!(notebook.pages[0].is_endless());
assert_eq!(notebook.pages[0].strokes.len(), 6);
let pen = &notebook.pages[0].strokes[1];
let home = crate::document::ENDLESS_HOME;
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
}
}
+6 -5
View File
@@ -669,7 +669,7 @@ impl UiState {
self.open_file_picker(PickerKind::Image);
}
/// Opens the file picker "Import" (PDF, Xournal++) in the last used folder.
/// Opens the file picker "Import" (PDF, Xournal++, Rnote) in the last used folder.
pub fn open_import_picker(&mut self) {
self.open_file_picker(PickerKind::Import);
}
@@ -2831,11 +2831,12 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
t!("Importieren"),
t!("📄 Importieren"),
"📄",
t!("Keine Ordner, PDF- oder Xournal++-Dateien"),
t!("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien"),
t!(
"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."
Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder \
werden übernommen, bei Xournal++ auch PDF-Hintergründe."
),
),
};
@@ -4196,8 +4197,8 @@ pub fn render_ui(
state.open_image_picker();
}
if ui
.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"))
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote) …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben"))
.clicked()
{
state.open_import_picker();
+46 -159
View File
@@ -7,23 +7,17 @@
//! (`<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::document::{Notebook, PageBackground, PdfBackground};
use crate::import::{self, ImportReport, Imported};
use crate::storage::{NotebookManifest, 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;
use crate::text::TextFont;
/// Is the file by its name a Xournal++ or Xournal notebook?
pub fn is_xopp_path(path: &Path) -> bool {
@@ -86,26 +80,9 @@ pub enum XoppItem {
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())
String::from_utf8(import::gunzip(bytes)?).map_err(|_| t!("Keine Xournal-Datei").to_string())
}
fn attributes(e: &BytesStart) -> HashMap<String, String> {
@@ -326,24 +303,18 @@ fn finish_item(element: Open, content: &str) -> Option<XoppItem> {
}
}
/// 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.
/// Our stroke width and sample pressures for Xournal widths (nominal width, then optionally
/// one width per point, or per segment in the original Xournal).
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
let per_point = widths.get(1..).unwrap_or_default();
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)
})
let scaled: Vec<f32> = (0..count)
.map(|i| per_point[i.min(per_point.len() - 1)] * scale)
.collect();
(width, pressures)
import::fit_widths(&scaled)
}
/// Finds a referenced file: as given, next to the Xournal file, or as Xournal++ attachment
@@ -370,23 +341,7 @@ pub fn create_notebook_from_xopp(
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, &notebook, &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)
}
}
import::create_notebook(base_dir, title, |dir| convert(&doc, xopp_path, dir))
}
/// PDF used as background: asset name and page sizes in points.
@@ -395,11 +350,7 @@ struct PdfSource {
sizes: Vec<(f32, f32)>,
}
fn convert(
doc: &XoppDocument,
xopp_path: &Path,
notebook_dir: &Path,
) -> Result<(Vec<Page>, u64, ImportReport), String> {
fn convert(doc: &XoppDocument, xopp_path: &Path, notebook_dir: &Path) -> Result<Imported, String> {
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
let mut pdfs: HashMap<String, Option<PdfSource>> = HashMap::new();
@@ -458,7 +409,9 @@ fn convert(
.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)),
Some(image) => {
strokes.push(import::image_stroke(new_id(), image.asset, placement))
}
None => report.skipped += 1,
}
}
@@ -473,42 +426,15 @@ fn convert(
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,
let points: Vec<glam::Vec2> = points.iter().map(|&p| map(p)).collect();
strokes.push(import::ink_stroke(
new_id(),
*color,
*highlighter,
&points,
width,
finalized: true,
image: None,
text: None,
});
&pressures,
));
}
XoppItem::Text {
mono,
@@ -522,34 +448,17 @@ fn convert(
} 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(
strokes.push(import::text_stroke(
new_id(),
content,
size * scale,
font,
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),
});
None,
*color,
));
}
XoppItem::Image { rect, data } => {
let compact: String =
@@ -568,7 +477,7 @@ fn convert(
glam::Vec2::new(l, b),
]
.map(map);
strokes.push(image_data(new_id(), image.asset, corners));
strokes.push(import::image_stroke(new_id(), image.asset, corners));
}
None => report.skipped += 1,
}
@@ -577,18 +486,20 @@ fn convert(
}
}
let page_data = storage::PageData {
format_version: storage::CURRENT_FORMAT_VERSION,
id: index as u64 + 1,
pages.push(import::page(
index as u64 + 1,
size,
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))
Ok(Imported {
pages,
next_stroke_id: next_id,
layout: None,
report,
})
}
fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) -> Option<PdfSource> {
@@ -598,35 +509,11 @@ fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) ->
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 crate::document::StrokeTool;
use crate::storage;
use std::io::Write;
fn gzip(xml: &str) -> Vec<u8> {
@@ -790,7 +677,7 @@ zweite Zeile</text>
assert!(!pen.vertices.is_empty());
let marker = &first.strokes[1];
assert_eq!(marker.tool, StrokeTool::Highlighter);
assert_eq!(marker.color[3], HIGHLIGHTER_ALPHA);
assert_eq!(marker.color[3], import::HIGHLIGHTER_ALPHA);
assert!(first.strokes[2].is_text());
for (i, page) in reloaded.pages[1..].iter().enumerate() {