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:
1 parent
2bd697ef82
commit
46026eb8e5
14 files changed
+1567
-187
No files matched your search
Generated
+130
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ¬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<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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 = ¬ebook.pages[0].strokes[1];
|
||||
let home = crate::document::ENDLESS_HOME;
|
||||
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||
Ok((notebook, manifest, report))
|
||||
})();
|
||||
match result {
|
||||
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
|
||||
Err(err) => {
|
||||
// Don't leave a half-finished notebook behind
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
|
||||
Reference in new issue
Block a user