Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ee32e1f7 | ||
|
|
397bf3bfe4 | ||
|
|
ca6f64ec8e | ||
|
|
46026eb8e5 | ||
|
|
2bd697ef82 | ||
|
|
b5d9d72e79 | ||
|
|
955eaa4095 |
No files matched your search
Generated
+134
-3
@@ -539,6 +539,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-url"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -790,6 +796,12 @@ dependencies = [
|
|||||||
"zlib-rs",
|
"zlib-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-cmp"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1381,6 +1393,12 @@ dependencies = [
|
|||||||
"quick-error",
|
"quick-error",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.2"
|
version = "2.14.2"
|
||||||
@@ -1393,14 +1411,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.34.2"
|
version = "0.37.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"base64",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
"egui-winit",
|
"egui-winit",
|
||||||
|
"flate2",
|
||||||
"fs2",
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
"hayro",
|
"hayro",
|
||||||
@@ -1411,6 +1431,8 @@ dependencies = [
|
|||||||
"octotablet",
|
"octotablet",
|
||||||
"pollster",
|
"pollster",
|
||||||
"printpdf",
|
"printpdf",
|
||||||
|
"quick-xml",
|
||||||
|
"resvg",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2079,6 +2101,12 @@ dependencies = [
|
|||||||
"pxfm",
|
"pxfm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pico-args"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2326,6 +2354,33 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2340,6 +2395,15 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2450,7 +2514,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"smithay-client-toolkit",
|
"smithay-client-toolkit",
|
||||||
"tiny-skia",
|
"tiny-skia 0.11.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2531,6 +2595,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simplecss"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2638,6 +2711,9 @@ name = "strict-num"
|
|||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
dependencies = [
|
||||||
|
"float-cmp",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strum"
|
name = "strum"
|
||||||
@@ -2667,6 +2743,16 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -2828,7 +2914,22 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"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]]
|
[[package]]
|
||||||
@@ -2842,6 +2943,17 @@ dependencies = [
|
|||||||
"strict-num",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -2978,6 +3090,25 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.34.2"
|
version = "0.37.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
@@ -48,6 +48,13 @@ fs2 = "0.4"
|
|||||||
hayro = "0.8"
|
hayro = "0.8"
|
||||||
hayro-svg = "0.8"
|
hayro-svg = "0.8"
|
||||||
|
|
||||||
|
# Import von Xournal++-Notizbüchern (gzip-komprimiertes XML mit base64-Bildern)
|
||||||
|
flate2 = "1.1"
|
||||||
|
quick-xml = "0.41"
|
||||||
|
base64 = "0.22"
|
||||||
|
# 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)
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++, Rnote und Saber importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||||
|
|
||||||
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||||
|
|
||||||
@@ -47,7 +47,10 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||||
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
||||||
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
||||||
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
- **Importieren** (☰-Menü oder Datei ins Fenster ziehen) – **PDF**: Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
||||||
|
**Xournal++** (`.xopp`, auch `.xoj` aus Xournal): Striche mit Stiftdruck, Textmarker, Texte, Bilder, liniertes/kariertes/gepunktetes Papier und PDF-Hintergründe werden übernommen (das PDF wird am alten Ort und neben der Datei gesucht). LaTeX-Formeln werden übersprungen.
|
||||||
|
**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.
|
||||||
|
**Saber** (`.sbn2` mit den Asset-Dateien `.sbn2.0`, `.sbn2.1`, … daneben, `.sba`-Archive, altes `.sbn`): Striche mit Stiftdruck, Textmarker, Formen, getippter Text, Bilder und PDF-Hintergründe.
|
||||||
|
|
||||||
### Seiten und Ansicht
|
### Seiten und Ansicht
|
||||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||||
@@ -169,6 +172,10 @@ src/
|
|||||||
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
||||||
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
||||||
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
||||||
|
├── import.rs # Import als neues Notizbuch (Gemeinsames)
|
||||||
|
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
|
||||||
|
├── rnote.rs # Import von Rnote-Notizbüchern
|
||||||
|
├── saber.rs # Import von Saber-Notizbüchern
|
||||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
|
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++, Rnote and Saber notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
|
||||||
|
|
||||||
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
|
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
|
||||||
|
|
||||||
@@ -47,7 +47,10 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`.
|
- **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`.
|
||||||
- **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window.
|
- **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window.
|
||||||
- **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot.
|
- **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot.
|
||||||
- **Import PDF** (☰ menu): Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
|
- **Import** (☰ menu, or drag the file into the window) – **PDF**: Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
|
||||||
|
**Xournal++** (`.xopp`, also `.xoj` from Xournal): strokes with pen pressure, highlighters, texts, images, lined/squared/dotted paper and PDF backgrounds are taken over (the PDF is looked for at its old place and next to the file). LaTeX formulas are skipped.
|
||||||
|
**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.
|
||||||
|
**Saber** (`.sbn2` with its asset files `.sbn2.0`, `.sbn2.1`, … next to it, `.sba` archives, old `.sbn`): strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.
|
||||||
|
|
||||||
### Pages and view
|
### Pages and view
|
||||||
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
|
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
|
||||||
@@ -171,6 +174,10 @@ src/
|
|||||||
├── assets.rs # Images and PDFs in the notebook folder
|
├── assets.rs # Images and PDFs in the notebook folder
|
||||||
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
||||||
├── pdf_merge.rs # Underlaying imported PDF pages on export
|
├── pdf_merge.rs # Underlaying imported PDF pages on export
|
||||||
|
├── import.rs # Import as a new notebook (shared parts)
|
||||||
|
├── xopp.rs # Import of Xournal++/Xournal notebooks
|
||||||
|
├── rnote.rs # Import of Rnote notebooks
|
||||||
|
├── saber.rs # Import of Saber notebooks
|
||||||
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||||
├── history.rs # Undo/redo
|
├── history.rs # Undo/redo
|
||||||
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
|||||||
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
||||||
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
||||||
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
||||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
|
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt.
|
||||||
|
|
||||||
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ Each page has its own history (`history.rs`): adding, removing, replacing and mo
|
|||||||
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
|
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
|
||||||
3. **Images:** inserted images (at most 300 dpi).
|
3. **Images:** inserted images (at most 300 dpi).
|
||||||
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
|
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
|
||||||
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice.
|
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before.
|
||||||
|
|
||||||
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
|
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,21 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.37.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.36.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.35.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.34.2" date="2026-10-26">
|
<release version="0.34.2" date="2026-10-26">
|
||||||
<description>
|
<description>
|
||||||
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
||||||
|
|||||||
@@ -805,6 +805,19 @@
|
|||||||
"dest": "cargo/vendor/d3d12-0.19.0",
|
"dest": "cargo/vendor/d3d12-0.19.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1156,6 +1169,19 @@
|
|||||||
"dest": "cargo/vendor/flate2-1.1.10",
|
"dest": "cargo/vendor/flate2-1.1.10",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1884,6 +1910,19 @@
|
|||||||
"dest": "cargo/vendor/image-webp-0.2.4",
|
"dest": "cargo/vendor/image-webp-0.2.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2794,6 +2833,19 @@
|
|||||||
"dest": "cargo/vendor/pic-scale-0.7.12",
|
"dest": "cargo/vendor/pic-scale-0.7.12",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3197,6 +3249,32 @@
|
|||||||
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
|
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3210,6 +3288,19 @@
|
|||||||
"dest": "cargo/vendor/ring-0.17.14",
|
"dest": "cargo/vendor/ring-0.17.14",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3483,6 +3574,19 @@
|
|||||||
"dest": "cargo/vendor/simdutf8-0.1.5",
|
"dest": "cargo/vendor/simdutf8-0.1.5",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3678,6 +3782,19 @@
|
|||||||
"dest": "cargo/vendor/subtle-2.6.1",
|
"dest": "cargo/vendor/subtle-2.6.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3873,6 +3990,19 @@
|
|||||||
"dest": "cargo/vendor/tiny-skia-0.11.4",
|
"dest": "cargo/vendor/tiny-skia-0.11.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3886,6 +4016,19 @@
|
|||||||
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
|
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4094,6 +4237,19 @@
|
|||||||
"dest": "cargo/vendor/url-2.5.8",
|
"dest": "cargo/vendor/url-2.5.8",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"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",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
|
|||||||
@@ -84,6 +84,21 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.37.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.36.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.35.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.34.2" date="2026-10-26">
|
<release version="0.34.2" date="2026-10-26">
|
||||||
<description>
|
<description>
|
||||||
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Import from Saber**: ☰ → Import (or dragging the file into the window) now also opens Saber notebooks (`.sbn2` with its asset files next to it, `.sba` archives, old `.sbn`) – strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds
|
||||||
|
- **Import from Xournal++ and Rnote**: ☰ → Import (or dragging the file into the window) now also opens Xournal++ (`.xopp`, `.xoj`) and Rnote (`.rnote`) notebooks as new notebooks – strokes with pen pressure, highlighters, shapes, texts, images, paper patterns and Xournal++ PDF backgrounds; PDF pages imported in Rnote become images, infinite Rnote documents an endless notebook
|
||||||
|
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
|
||||||
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
|
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
|
||||||
- **Fling fix**: A finger swipe is no longer lost when the view touches the edge of the notebook, so the page glides on after a fast swipe; the fling diagnostics (`INK_NOTES_DEBUG_FLING=1`) log moves, distance and timing
|
- **Fling fix**: A finger swipe is no longer lost when the view touches the edge of the notebook, so the page glides on after a fast swipe; the fling diagnostics (`INK_NOTES_DEBUG_FLING=1`) log moves, distance and timing
|
||||||
- **Feedback round**: The eraser size is set in a menu like the pens (four presets and a slider); corners of snapped triangles and polygons stay pointed; slides (landscape PDFs) get flat tiles in the page overview without an empty strip
|
- **Feedback round**: The eraser size is set in a menu like the pens (four presets and a slider); corners of snapped triangles and polygons stay pointed; slides (landscape PDFs) get flat tiles in the page overview without an empty strip
|
||||||
|
|||||||
+18
-14
@@ -2216,10 +2216,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
|
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote/Saber: importieren
|
||||||
WindowEvent::DroppedFile(path) => {
|
WindowEvent::DroppedFile(path) => {
|
||||||
if crate::file_picker::is_pdf_path(&path) {
|
if crate::import::is_importable_path(&path) {
|
||||||
ui_state.queued_actions.push(UiAction::ImportPdf(path));
|
ui_state.queued_actions.push(UiAction::ImportFile(path));
|
||||||
} else if crate::assets::is_supported_image_path(&path) {
|
} else if crate::assets::is_supported_image_path(&path) {
|
||||||
let (sx, sy) = cursor_screen_pos;
|
let (sx, sy) = cursor_screen_pos;
|
||||||
let center = canvas.screen_to_page_clamped(sx, sy);
|
let center = canvas.screen_to_page_clamped(sx, sy);
|
||||||
@@ -3837,18 +3837,15 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::ImportPdf(path) => {
|
UiAction::ImportFile(path) => {
|
||||||
// Neben das gerade geöffnete Notizbuch
|
// Neben das gerade geöffnete Notizbuch
|
||||||
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
let folder = ui_state.library.folder_of(&manifest.id).to_string();
|
||||||
let title = path
|
let title = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
.unwrap_or_else(|| "PDF".to_string());
|
.unwrap_or_else(|| "Import".to_string());
|
||||||
let result = std::fs::read(&path)
|
match crate::import::import_file(&storage_base_dir, &title, &path) {
|
||||||
.map_err(|e| e.to_string())
|
Ok((new_nb, new_manifest, new_dir, report)) => {
|
||||||
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
|
||||||
match result {
|
|
||||||
Ok((new_nb, new_manifest, new_dir)) => {
|
|
||||||
let id = new_manifest.id.clone();
|
let id = new_manifest.id.clone();
|
||||||
change_library(&storage_base_dir, &mut ui_state, |library| {
|
change_library(&storage_base_dir, &mut ui_state, |library| {
|
||||||
library.move_notebook(&id, &folder)
|
library.move_notebook(&id, &folder)
|
||||||
@@ -3867,15 +3864,22 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.reset_view();
|
canvas.reset_view();
|
||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
ui_state.set_export_message(t!(
|
let mut message = t!(
|
||||||
"'{title}' importiert ({count} Seiten)",
|
"'{title}' importiert ({count} Seiten)",
|
||||||
title = manifest.title,
|
title = manifest.title,
|
||||||
count = notebook.pages.len()
|
count = notebook.pages.len()
|
||||||
));
|
);
|
||||||
|
if let Some(pdf) = &report.missing_pdf {
|
||||||
|
message.push_str(&t!(", PDF-Hintergrund nicht gefunden: {file}", file = pdf));
|
||||||
|
}
|
||||||
|
if report.skipped > 0 {
|
||||||
|
message.push_str(&t!(", {count} Elemente übersprungen", count = report.skipped));
|
||||||
|
}
|
||||||
|
ui_state.set_export_message(message);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("[PDF-Import] '{}': {err}", path.display());
|
eprintln!("[Import] '{}': {err}", path.display());
|
||||||
ui_state.set_export_message(t!("PDF nicht importiert: {err}", err = err));
|
ui_state.set_export_message(t!("Nicht importiert: {err}", err = err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
|
|||||||
+30
-15
@@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
|
|
||||||
// Geschlossener Pfad um den Ribbon-Rand:
|
// Geschlossener Pfad um den Ribbon-Rand:
|
||||||
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
||||||
let mut ring = Vec::with_capacity(n * 2);
|
let outline: Vec<glam::Vec2> = (0..n)
|
||||||
|
.map(|i| stroke.vertices[i * 2].position)
|
||||||
// 1. Linke Hüllkurve vorwärts
|
.chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
|
||||||
for i in 0..n {
|
.map(glam::Vec2::from)
|
||||||
let v = stroke.vertices[i * 2];
|
.collect();
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
// The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
// tell apart; fewer points make the PDF, PNG export and copying as an image much faster
|
||||||
}
|
let outline = if exact_outlines() {
|
||||||
|
outline
|
||||||
// 2. Rechte Hüllkurve rückwärts
|
} else {
|
||||||
for i in (0..n).rev() {
|
crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
|
||||||
let v = stroke.vertices[i * 2 + 1];
|
};
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
let ring: Vec<(Point, bool)> = outline
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
.iter()
|
||||||
}
|
.map(|p| {
|
||||||
|
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(x), Mm(y)), false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let fill_color = Color::Rgb(Rgb::new(
|
let fill_color = Color::Rgb(Rgb::new(
|
||||||
stroke.color[0],
|
stroke.color[0],
|
||||||
@@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
layer.add_polygon(poly);
|
layer.add_polygon(poly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Largest deviation of an exported stroke outline from the drawn one, in page units
|
||||||
|
/// (0.01 mm: a tenth of a pixel at 200 dpi).
|
||||||
|
pub const OUTLINE_TOLERANCE: f32 = 0.1;
|
||||||
|
|
||||||
|
/// `INK_NOTES_EXACT_OUTLINES=1` exports every point of the stroke outlines, as before this change
|
||||||
|
/// (fallback in case the simplified outlines ever look wrong).
|
||||||
|
fn exact_outlines() -> bool {
|
||||||
|
static EXACT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
||||||
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||||
if stroke.points.is_empty() {
|
if stroke.points.is_empty() {
|
||||||
|
|||||||
+20
-6
@@ -1,4 +1,4 @@
|
|||||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
|
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote, Saber).
|
||||||
//!
|
//!
|
||||||
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||||
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||||
@@ -22,8 +22,8 @@ pub struct PickerEntry {
|
|||||||
pub enum PickerKind {
|
pub enum PickerKind {
|
||||||
/// Bilder zum Einfügen auf der aktuellen Seite
|
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||||
Image,
|
Image,
|
||||||
/// PDF zum Importieren als neues Notizbuch
|
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
|
||||||
Pdf,
|
Import,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PickerKind {
|
impl PickerKind {
|
||||||
@@ -31,7 +31,7 @@ impl PickerKind {
|
|||||||
pub fn accepts(self, path: &Path) -> bool {
|
pub fn accepts(self, path: &Path) -> bool {
|
||||||
match self {
|
match self {
|
||||||
PickerKind::Image => is_supported_image_path(path),
|
PickerKind::Image => is_supported_image_path(path),
|
||||||
PickerKind::Pdf => is_pdf_path(path),
|
PickerKind::Import => crate::import::is_importable_path(path),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ impl FilePicker {
|
|||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
let preferred = match kind {
|
let preferred = match kind {
|
||||||
PickerKind::Image => tn!("🖼 Bilder"),
|
PickerKind::Image => tn!("🖼 Bilder"),
|
||||||
PickerKind::Pdf => tn!("⬇ Downloads"),
|
PickerKind::Import => tn!("⬇ Downloads"),
|
||||||
};
|
};
|
||||||
let all = shortcuts();
|
let all = shortcuts();
|
||||||
all.iter()
|
all.iter()
|
||||||
@@ -192,6 +192,10 @@ mod tests {
|
|||||||
".versteckt.png",
|
".versteckt.png",
|
||||||
"c.webp",
|
"c.webp",
|
||||||
"Skript.PDF",
|
"Skript.PDF",
|
||||||
|
"mathe.xopp",
|
||||||
|
"skizze.rnote",
|
||||||
|
"Uni.sbn2",
|
||||||
|
"Uni.sbn2.0",
|
||||||
] {
|
] {
|
||||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||||
}
|
}
|
||||||
@@ -210,7 +214,17 @@ mod tests {
|
|||||||
names(PickerKind::Image),
|
names(PickerKind::Image),
|
||||||
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
||||||
);
|
);
|
||||||
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
|
assert_eq!(
|
||||||
|
names(PickerKind::Import),
|
||||||
|
[
|
||||||
|
"aOrdner",
|
||||||
|
"zOrdner",
|
||||||
|
"mathe.xopp",
|
||||||
|
"skizze.rnote",
|
||||||
|
"Skript.PDF",
|
||||||
|
"Uni.sbn2"
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+129
@@ -352,6 +352,63 @@ fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simplifies a closed outline (the last point connects back to the first) so that no point
|
||||||
|
/// of the original lies farther than `tolerance` from the result. Unlike
|
||||||
|
/// [`ramer_douglas_peucker`], it measures the distance to the segment, not to the infinite
|
||||||
|
/// line, so stroke tips that turn back are kept. Works without recursion, so long strokes
|
||||||
|
/// cannot overflow the stack.
|
||||||
|
pub fn simplify_closed_outline(points: &[Vec2], tolerance: f32) -> Vec<Vec2> {
|
||||||
|
if points.len() <= 3 {
|
||||||
|
return points.to_vec();
|
||||||
|
}
|
||||||
|
// Two chains between the first point and the point farthest from it: the start of an
|
||||||
|
// outline often lies on top of its end (round cap), which would leave no chord to measure
|
||||||
|
let far = (1..points.len())
|
||||||
|
.max_by(|&i, &j| {
|
||||||
|
points[i]
|
||||||
|
.distance_squared(points[0])
|
||||||
|
.total_cmp(&points[j].distance_squared(points[0]))
|
||||||
|
})
|
||||||
|
.unwrap_or(1);
|
||||||
|
let mut keep = vec![false; points.len()];
|
||||||
|
keep[0] = true;
|
||||||
|
keep[far] = true;
|
||||||
|
let mut stack = vec![(0, far), (far, points.len())];
|
||||||
|
while let Some((start, end)) = stack.pop() {
|
||||||
|
// `end == points.len()` closes the ring back to the first point
|
||||||
|
let (a, b) = (points[start], points[end % points.len()]);
|
||||||
|
let mut worst = (0.0_f32, start);
|
||||||
|
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
|
||||||
|
let d = point_to_segment_dist(p, a, b);
|
||||||
|
if d > worst.0 {
|
||||||
|
worst = (d, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worst.0 > tolerance {
|
||||||
|
keep[worst.1] = true;
|
||||||
|
stack.push((start, worst.1));
|
||||||
|
stack.push((worst.1, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.zip(keep)
|
||||||
|
.filter_map(|(&p, keep)| keep.then_some(p))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstand eines Punktes `p` zur Strecke von `a` nach `b`.
|
||||||
|
#[inline]
|
||||||
|
pub fn point_to_segment_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
|
let ab = b - a;
|
||||||
|
let len_sq = ab.length_squared();
|
||||||
|
if len_sq < 1e-12 {
|
||||||
|
return p.distance(a);
|
||||||
|
}
|
||||||
|
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||||
|
p.distance(a + ab * t)
|
||||||
|
}
|
||||||
|
|
||||||
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
@@ -2327,6 +2384,78 @@ pub fn shape_to_stroke(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Largest distance of any point of `original` from the closed outline `simplified`.
|
||||||
|
fn outline_deviation(original: &[Vec2], simplified: &[Vec2]) -> f32 {
|
||||||
|
original
|
||||||
|
.iter()
|
||||||
|
.map(|&p| {
|
||||||
|
(0..simplified.len())
|
||||||
|
.map(|i| {
|
||||||
|
point_to_segment_dist(
|
||||||
|
p,
|
||||||
|
simplified[i],
|
||||||
|
simplified[(i + 1) % simplified.len()],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
})
|
||||||
|
.fold(0.0, f32::max)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simplified_stroke_outline_stays_within_tolerance() {
|
||||||
|
// A handwritten loop like a letter, as the pen delivers it
|
||||||
|
let samples: Vec<StylusSample> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / 29.0 * std::f32::consts::TAU * 1.3;
|
||||||
|
StylusSample {
|
||||||
|
position: Vec2::new(500.0 + 12.0 * t.cos() + t * 4.0, 800.0 + 20.0 * t.sin()),
|
||||||
|
pressure: 0.4 + 0.3 * (t * 0.7).sin().abs(),
|
||||||
|
timestamp: i as f64 * 0.008,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
5.0,
|
||||||
|
Default::default(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let n = vertices.len() / 2;
|
||||||
|
let outline: Vec<Vec2> = (0..n)
|
||||||
|
.map(|i| vertices[i * 2].position)
|
||||||
|
.chain((0..n).rev().map(|i| vertices[i * 2 + 1].position))
|
||||||
|
.map(Vec2::from)
|
||||||
|
.collect();
|
||||||
|
let tolerance = 0.1;
|
||||||
|
let simplified = simplify_closed_outline(&outline, tolerance);
|
||||||
|
let deviation = outline_deviation(&outline, &simplified);
|
||||||
|
assert!(deviation <= tolerance + 1e-4, "{deviation}");
|
||||||
|
assert!(
|
||||||
|
simplified.len() * 4 < outline.len(),
|
||||||
|
"{} -> {} points",
|
||||||
|
outline.len(),
|
||||||
|
simplified.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simplified_outline_keeps_tips_that_turn_back() {
|
||||||
|
// Thin hairpin: out along y = 0 to x = 20, back along y = 0.05. Measured against the
|
||||||
|
// infinite line, the tip would vanish.
|
||||||
|
let mut outline: Vec<Vec2> = (0..=20).map(|x| Vec2::new(x as f32, 0.0)).collect();
|
||||||
|
outline.extend((0..20).rev().map(|x| Vec2::new(x as f32, 0.05)));
|
||||||
|
let simplified = simplify_closed_outline(&outline, 0.1);
|
||||||
|
let reach = simplified.iter().map(|p| p.x).fold(f32::MIN, f32::max);
|
||||||
|
assert_eq!(reach, 20.0, "{simplified:?}");
|
||||||
|
assert!(outline_deviation(&outline, &simplified) <= 0.1);
|
||||||
|
assert!(simplified.len() <= 4, "{simplified:?}");
|
||||||
|
// Tiny outlines stay as they are
|
||||||
|
let dot = [Vec2::ZERO, Vec2::X, Vec2::Y];
|
||||||
|
assert_eq!(simplify_closed_outline(&dot, 0.1), dot.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_line() {
|
fn test_detect_line() {
|
||||||
// Leicht zittrige diagonale Punktfolge
|
// Leicht zittrige diagonale Punktfolge
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ mod tests {
|
|||||||
("text.rs", include_str!("text.rs")),
|
("text.rs", include_str!("text.rs")),
|
||||||
("ui.rs", include_str!("ui.rs")),
|
("ui.rs", include_str!("ui.rs")),
|
||||||
("updater.rs", include_str!("updater.rs")),
|
("updater.rs", include_str!("updater.rs")),
|
||||||
|
("import.rs", include_str!("import.rs")),
|
||||||
|
("rnote.rs", include_str!("rnote.rs")),
|
||||||
|
("saber.rs", include_str!("saber.rs")),
|
||||||
|
("xopp.rs", include_str!("xopp.rs")),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
||||||
|
|||||||
+17
-7
@@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
||||||
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
|
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
|
||||||
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
|
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
|
||||||
("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."),
|
|
||||||
("Datei existiert nicht", "File does not exist"),
|
("Datei existiert nicht", "File does not exist"),
|
||||||
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
||||||
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
||||||
@@ -150,7 +149,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Keine manifest.json vorhanden", "No manifest.json present"),
|
("Keine manifest.json vorhanden", "No manifest.json present"),
|
||||||
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
|
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
|
||||||
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
|
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
|
||||||
("Keine Ordner oder PDF-Dateien", "No folders or PDF files"),
|
|
||||||
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
||||||
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
||||||
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
|
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
|
||||||
@@ -234,9 +232,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
||||||
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
||||||
("Pastell-Cyan", "Pastel cyan"),
|
("Pastell-Cyan", "Pastel cyan"),
|
||||||
("PDF importieren", "Import PDF"),
|
|
||||||
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
|
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
|
||||||
("PDF nicht importiert: {err}", "PDF not imported: {err}"),
|
|
||||||
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
|
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
|
||||||
("PDF ohne Seite", "PDF without a page"),
|
("PDF ohne Seite", "PDF without a page"),
|
||||||
("PDF-Export läuft…", "PDF export running…"),
|
("PDF-Export läuft…", "PDF export running…"),
|
||||||
@@ -368,7 +364,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
||||||
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
||||||
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
||||||
("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"),
|
|
||||||
("weich", "soft"),
|
("weich", "soft"),
|
||||||
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
||||||
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
||||||
@@ -430,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
||||||
("📂 Ordner öffnen", "📂 Open folder"),
|
("📂 Ordner öffnen", "📂 Open folder"),
|
||||||
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||||
("📄 PDF importieren …", "📄 Import PDF …"),
|
|
||||||
("📄 Feste Seiten", "📄 Fixed pages"),
|
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||||
("📄 PDF importieren", "📄 Import PDF"),
|
|
||||||
("📋 Einfügen", "📋 Paste"),
|
("📋 Einfügen", "📋 Paste"),
|
||||||
("📑 Inhalt", "📑 Contents"),
|
("📑 Inhalt", "📑 Contents"),
|
||||||
("📚 Alle", "📚 All"),
|
("📚 Alle", "📚 All"),
|
||||||
@@ -454,4 +447,21 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
||||||
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
||||||
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
||||||
|
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
|
||||||
|
(", {count} Elemente übersprungen", ", {count} elements skipped"),
|
||||||
|
("Nicht importiert: {err}", "Not imported: {err}"),
|
||||||
|
("Importieren", "Import"),
|
||||||
|
("📄 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 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"),
|
||||||
|
("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)", "No folders or importable files (PDF, Xournal++, Rnote, Saber)"),
|
||||||
|
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ und Saber 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), Rnote (.rnote) and Saber (.sbn2, .sba): strokes, shapes, texts and images are taken over, for Xournal++ and Saber also PDF backgrounds."),
|
||||||
|
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++, Rnote and Saber as a new notebook and write on them"),
|
||||||
|
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
|
||||||
|
("Keine Saber-Datei", "Not a Saber file"),
|
||||||
|
("Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern", "Saber file too old: please open it in a current Saber and save it again"),
|
||||||
];
|
];
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
|
||||||
|
//! (`xopp`), Rnote (`rnote`) and Saber (`saber`). 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)
|
||||||
|
|| crate::saber::is_saber_path(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports a PDF, Xournal++, Rnote or Saber 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 if crate::saber::is_saber_path(path) {
|
||||||
|
crate::saber::create_notebook_from_saber(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
|
||||||
|
pub 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).
|
||||||
|
pub 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,13 +13,16 @@ pub mod geometry;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod import;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod osk;
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
|
pub mod rnote;
|
||||||
pub mod ruler;
|
pub mod ruler;
|
||||||
|
pub mod saber;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
@@ -28,3 +31,4 @@ pub mod text;
|
|||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
|
pub mod xopp;
|
||||||
+907
@@ -0,0 +1,907 @@
|
|||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
import::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))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = import::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+904
@@ -0,0 +1,904 @@
|
|||||||
|
//! Import of Saber notebooks (.sbn2, .sba, old .sbn) (Issue #47).
|
||||||
|
//!
|
||||||
|
//! A `.sbn2` file is a BSON document; images and PDFs lie next to it as `<file>.sbn2.0`,
|
||||||
|
//! `<file>.sbn2.1`, …; a `.sba` archive is a zip with `main.sbn2` and its assets, and the old
|
||||||
|
//! `.sbn` is the same document as JSON. Pages (`z`) hold strokes (`s`), images (`i`), typed
|
||||||
|
//! text as a Quill delta (`q`) and an optional background image or PDF page (`b`). Points are
|
||||||
|
//! three little-endian `f32` (x, y, pressure) since format version 13.
|
||||||
|
//!
|
||||||
|
//! Stroke widths follow perfect_freehand: the diameter is `size · (1 − thinning · (1 − 2p))`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::document::{Notebook, PageBackground, PdfBackground};
|
||||||
|
use crate::import::{self, ImportReport, Imported};
|
||||||
|
use crate::storage::{NotebookManifest, StrokeData};
|
||||||
|
use crate::t;
|
||||||
|
use crate::text::TextFont;
|
||||||
|
|
||||||
|
/// Saber's default page size, used when a page has none.
|
||||||
|
const DEFAULT_PAGE: glam::Vec2 = glam::Vec2::new(1000.0, 1400.0);
|
||||||
|
|
||||||
|
/// Is the file by its name a Saber notebook?
|
||||||
|
pub fn is_saber_path(path: &Path) -> bool {
|
||||||
|
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||||
|
["sbn2", "sba", "sbn"]
|
||||||
|
.iter()
|
||||||
|
.any(|x| e.eq_ignore_ascii_case(x))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A BSON (or JSON) value as far as Saber uses it.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Bson {
|
||||||
|
Null,
|
||||||
|
Bool(bool),
|
||||||
|
Int(i64),
|
||||||
|
Double(f64),
|
||||||
|
Str(String),
|
||||||
|
Binary(Vec<u8>),
|
||||||
|
Array(Vec<Bson>),
|
||||||
|
Doc(Vec<(String, Bson)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bson {
|
||||||
|
pub fn get(&self, key: &str) -> Option<&Bson> {
|
||||||
|
match self {
|
||||||
|
Bson::Doc(fields) => fields
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == key)
|
||||||
|
.map(|(_, v)| v)
|
||||||
|
.filter(|v| **v != Bson::Null),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
|
match *self {
|
||||||
|
Bson::Double(v) if v.is_finite() => Some(v),
|
||||||
|
Bson::Int(v) => Some(v as f64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_i64(&self) -> Option<i64> {
|
||||||
|
match *self {
|
||||||
|
Bson::Int(v) => Some(v),
|
||||||
|
Bson::Double(v) if v.is_finite() => Some(v as i64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Bson::Str(s) => Some(s),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_array(&self) -> &[Bson] {
|
||||||
|
match self {
|
||||||
|
Bson::Array(items) => items,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(&self, key: &str) -> Option<f64> {
|
||||||
|
self.get(key)?.as_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool(&self, key: &str) -> Option<bool> {
|
||||||
|
match self.get(key)? {
|
||||||
|
Bson::Bool(b) => Some(*b),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_json(value: serde_json::Value) -> Bson {
|
||||||
|
use serde_json::Value;
|
||||||
|
match value {
|
||||||
|
Value::Null => Bson::Null,
|
||||||
|
Value::Bool(b) => Bson::Bool(b),
|
||||||
|
Value::Number(n) => n
|
||||||
|
.as_i64()
|
||||||
|
.map(Bson::Int)
|
||||||
|
.unwrap_or_else(|| Bson::Double(n.as_f64().unwrap_or(0.0))),
|
||||||
|
Value::String(s) => Bson::Str(s),
|
||||||
|
Value::Array(items) => Bson::Array(items.into_iter().map(Bson::from_json).collect()),
|
||||||
|
Value::Object(map) => Bson::Doc(
|
||||||
|
map.into_iter()
|
||||||
|
.map(|(k, v)| (k, Bson::from_json(v)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a BSON document.
|
||||||
|
pub fn parse_bson(bytes: &[u8]) -> Result<Bson, String> {
|
||||||
|
let mut pos = 0;
|
||||||
|
document(bytes, &mut pos, 0).ok_or_else(|| t!("Keine Saber-Datei").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take<'a>(bytes: &'a [u8], pos: &mut usize, n: usize) -> Option<&'a [u8]> {
|
||||||
|
let slice = bytes.get(*pos..pos.checked_add(n)?)?;
|
||||||
|
*pos += n;
|
||||||
|
Some(slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn int32(bytes: &[u8], pos: &mut usize) -> Option<i32> {
|
||||||
|
Some(i32::from_le_bytes(take(bytes, pos, 4)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cstring(bytes: &[u8], pos: &mut usize) -> Option<String> {
|
||||||
|
let len = bytes.get(*pos..)?.iter().position(|&b| b == 0)?;
|
||||||
|
let s = String::from_utf8_lossy(take(bytes, pos, len)?).into_owned();
|
||||||
|
*pos += 1;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document (or array, which is a document with the keys "0", "1", …).
|
||||||
|
fn document(bytes: &[u8], pos: &mut usize, depth: usize) -> Option<Bson> {
|
||||||
|
if depth > 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = *pos;
|
||||||
|
let len = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||||
|
let end = start.checked_add(len)?;
|
||||||
|
if end > bytes.len() || len < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
loop {
|
||||||
|
let kind = *bytes.get(*pos)?;
|
||||||
|
*pos += 1;
|
||||||
|
if kind == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let key = cstring(bytes, pos)?;
|
||||||
|
let value = match kind {
|
||||||
|
0x01 => Bson::Double(f64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?)),
|
||||||
|
0x02 => {
|
||||||
|
let n = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||||
|
let s = take(bytes, pos, n)?;
|
||||||
|
Bson::Str(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned())
|
||||||
|
}
|
||||||
|
0x03 => document(bytes, pos, depth + 1)?,
|
||||||
|
0x04 => match document(bytes, pos, depth + 1)? {
|
||||||
|
Bson::Doc(items) => Bson::Array(items.into_iter().map(|(_, v)| v).collect()),
|
||||||
|
other => other,
|
||||||
|
},
|
||||||
|
0x05 => {
|
||||||
|
let n = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||||
|
*pos += 1; // subtype
|
||||||
|
Bson::Binary(take(bytes, pos, n)?.to_vec())
|
||||||
|
}
|
||||||
|
0x07 => {
|
||||||
|
take(bytes, pos, 12)?;
|
||||||
|
Bson::Null
|
||||||
|
}
|
||||||
|
0x08 => Bson::Bool(*take(bytes, pos, 1)?.first()? != 0),
|
||||||
|
0x09 | 0x11 | 0x12 => {
|
||||||
|
Bson::Int(i64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
0x0A | 0x06 | 0x7F | 0xFF => Bson::Null,
|
||||||
|
0x10 => Bson::Int(int32(bytes, pos)? as i64),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
fields.push((key, value));
|
||||||
|
}
|
||||||
|
(*pos == end).then_some(Bson::Doc(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the files of a zip archive (stored or deflated), at most `limit` bytes in total.
|
||||||
|
fn unzip(bytes: &[u8], limit: usize) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||||
|
let broken = || t!("Datei beschädigt: {err}", err = "zip").to_string();
|
||||||
|
let u16_at = |at: usize| -> Option<usize> {
|
||||||
|
Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?) as usize)
|
||||||
|
};
|
||||||
|
let u32_at = |at: usize| -> Option<usize> {
|
||||||
|
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize)
|
||||||
|
};
|
||||||
|
// End of central directory: the last record with its signature
|
||||||
|
let eocd = (0..bytes.len().saturating_sub(21))
|
||||||
|
.rev()
|
||||||
|
.find(|&i| bytes[i..].starts_with(&[0x50, 0x4b, 0x05, 0x06]))
|
||||||
|
.ok_or_else(broken)?;
|
||||||
|
let count = u16_at(eocd + 10).ok_or_else(broken)?;
|
||||||
|
let mut at = u32_at(eocd + 16).ok_or_else(broken)?;
|
||||||
|
let mut files = HashMap::new();
|
||||||
|
let mut total = 0;
|
||||||
|
for _ in 0..count {
|
||||||
|
let entry = (|| {
|
||||||
|
if !bytes.get(at..)?.starts_with(&[0x50, 0x4b, 0x01, 0x02]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let method = u16_at(at + 10)?;
|
||||||
|
let compressed = u32_at(at + 20)?;
|
||||||
|
let size = u32_at(at + 24)?;
|
||||||
|
let name_len = u16_at(at + 28)?;
|
||||||
|
let skip = name_len + u16_at(at + 30)? + u16_at(at + 32)?;
|
||||||
|
let local = u32_at(at + 42)?;
|
||||||
|
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + name_len)?);
|
||||||
|
at += 46 + skip;
|
||||||
|
let data_start = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?;
|
||||||
|
let data = bytes.get(data_start..data_start.checked_add(compressed)?)?;
|
||||||
|
Some((name.into_owned(), method, size, data))
|
||||||
|
})();
|
||||||
|
let (name, method, size, data) = entry.ok_or_else(broken)?;
|
||||||
|
total += size;
|
||||||
|
if total > limit {
|
||||||
|
return Err(broken());
|
||||||
|
}
|
||||||
|
let content = match method {
|
||||||
|
0 => data.to_vec(),
|
||||||
|
8 => {
|
||||||
|
let mut out = Vec::with_capacity(size);
|
||||||
|
flate2::read::DeflateDecoder::new(data)
|
||||||
|
.take(size as u64)
|
||||||
|
.read_to_end(&mut out)
|
||||||
|
.map_err(|_| broken())?;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
_ => return Err(broken()),
|
||||||
|
};
|
||||||
|
files.insert(name, content);
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document and a way to get its assets (by index).
|
||||||
|
struct Source {
|
||||||
|
doc: Bson,
|
||||||
|
assets: Box<dyn Fn(usize) -> Option<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(path: &Path) -> Result<Source, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
if bytes.starts_with(&[0x50, 0x4b, 0x03, 0x04]) {
|
||||||
|
let files = unzip(&bytes, 1024 * 1024 * 1024)?;
|
||||||
|
let main = files
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name.ends_with(".sbn2"))
|
||||||
|
.map(|(name, data)| (name.clone(), data))
|
||||||
|
.ok_or_else(|| t!("Keine Saber-Datei").to_string())?;
|
||||||
|
let doc = parse_bson(main.1)?;
|
||||||
|
let prefix = main.0;
|
||||||
|
return Ok(Source {
|
||||||
|
doc,
|
||||||
|
assets: Box::new(move |i| files.get(&format!("{prefix}.{i}")).cloned()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// .sbn2 is BSON, the old .sbn JSON
|
||||||
|
let doc = match parse_bson(&bytes) {
|
||||||
|
Ok(doc) => doc,
|
||||||
|
Err(err) => serde_json::from_slice(&bytes)
|
||||||
|
.map(Bson::from_json)
|
||||||
|
.map_err(|_| err)?,
|
||||||
|
};
|
||||||
|
let base = path.to_path_buf();
|
||||||
|
Ok(Source {
|
||||||
|
doc,
|
||||||
|
assets: Box::new(move |i| {
|
||||||
|
std::fs::read(PathBuf::from(format!("{}.{i}", base.display()))).ok()
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color stored as ARGB integer.
|
||||||
|
fn argb(value: Option<&Bson>) -> [f32; 4] {
|
||||||
|
let v = value.and_then(Bson::as_i64).unwrap_or(0xff000000) as u32;
|
||||||
|
[
|
||||||
|
((v >> 16) & 0xff) as f32 / 255.0,
|
||||||
|
((v >> 8) & 0xff) as f32 / 255.0,
|
||||||
|
(v & 0xff) as f32 / 255.0,
|
||||||
|
(v >> 24) as f32 / 255.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points of a stroke: `f32` triples (version 13 on) or `{x, y, p}` documents.
|
||||||
|
fn points(stroke: &Bson) -> Vec<(glam::Vec2, Option<f32>)> {
|
||||||
|
let offset = glam::Vec2::new(
|
||||||
|
stroke.num("ox").unwrap_or(0.0) as f32,
|
||||||
|
stroke.num("oy").unwrap_or(0.0) as f32,
|
||||||
|
);
|
||||||
|
let Some(list) = stroke.get("p") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
list.as_array()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|point| match point {
|
||||||
|
Bson::Binary(data) => {
|
||||||
|
let values: Vec<f32> = data
|
||||||
|
.as_chunks::<4>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|b| f32::from_le_bytes(*b))
|
||||||
|
.collect();
|
||||||
|
match values[..] {
|
||||||
|
[x, y] => Some((glam::Vec2::new(x, y), None)),
|
||||||
|
[x, y, p, ..] => Some((glam::Vec2::new(x, y), Some(p))),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc @ Bson::Doc(_) => Some((
|
||||||
|
glam::Vec2::new(doc.num("x")? as f32, doc.num("y")? as f32),
|
||||||
|
doc.num("p").map(|p| p as f32),
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|(p, _)| p.is_finite())
|
||||||
|
.map(|(p, pressure)| (p + offset, pressure))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline points of a shape stroke (circle or rectangle).
|
||||||
|
fn shape_points(stroke: &Bson, shape: &str) -> Vec<glam::Vec2> {
|
||||||
|
let n = |k| stroke.num(k).unwrap_or(0.0) as f32;
|
||||||
|
match shape {
|
||||||
|
"circle" => {
|
||||||
|
let (center, r) = (glam::Vec2::new(n("cx"), n("cy")), n("r"));
|
||||||
|
(0..=64)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / 64.0 * std::f32::consts::TAU;
|
||||||
|
center + glam::Vec2::new(a.cos(), a.sin()) * r
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
"rect" => {
|
||||||
|
let (l, t, w, h) = (n("rl"), n("rt"), n("rw"), n("rh"));
|
||||||
|
[(l, t), (l + w, t), (l + w, t + h), (l, t + h), (l, t)]
|
||||||
|
.map(|(x, y)| glam::Vec2::new(x, y))
|
||||||
|
.to_vec()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed text of a page: the inserted strings of its Quill delta.
|
||||||
|
fn quill_text(delta: &Bson) -> String {
|
||||||
|
let text: String = delta
|
||||||
|
.as_array()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|op| op.get("insert")?.as_str())
|
||||||
|
.collect();
|
||||||
|
text.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new notebook from a Saber file.
|
||||||
|
pub fn create_notebook_from_saber(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||||
|
let source = load(path)?;
|
||||||
|
// The very first files were only a list of strokes; versions up to 7 kept strokes and
|
||||||
|
// images outside the pages, which is read as well
|
||||||
|
if !matches!(source.doc, Bson::Doc(_)) {
|
||||||
|
return Err(t!(
|
||||||
|
"Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern"
|
||||||
|
)
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
import::create_notebook(base_dir, title, |dir| Ok(convert(&source, dir)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PDF of the notebook in our asset folder.
|
||||||
|
struct PdfSource {
|
||||||
|
asset: String,
|
||||||
|
sizes: Vec<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(source: &Source, notebook_dir: &Path) -> Imported {
|
||||||
|
let doc = &source.doc;
|
||||||
|
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||||
|
let mut report = ImportReport::default();
|
||||||
|
let mut pdfs: HashMap<usize, Option<PdfSource>> = HashMap::new();
|
||||||
|
let background = match doc.get("p").and_then(Bson::as_str) {
|
||||||
|
Some("lined" | "college" | "college-rtl" | "cornell") => PageBackground::Lined,
|
||||||
|
Some("grid") => PageBackground::Grid5mm,
|
||||||
|
Some("dots") => PageBackground::DotGrid,
|
||||||
|
_ => PageBackground::Blank,
|
||||||
|
};
|
||||||
|
let line_height = doc.num("l").unwrap_or(40.0) as f32;
|
||||||
|
let mut xpages: Vec<&Bson> = doc
|
||||||
|
.get("z")
|
||||||
|
.map(Bson::as_array)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.collect();
|
||||||
|
let empty = Bson::Doc(Vec::new());
|
||||||
|
if xpages.is_empty() {
|
||||||
|
xpages.push(&empty);
|
||||||
|
}
|
||||||
|
// Before version 8 strokes and images were stored outside the pages, with a page index
|
||||||
|
let old_items = |key: &str, index: usize| -> Vec<&Bson> {
|
||||||
|
doc.get(key)
|
||||||
|
.map(Bson::as_array)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.num("i").unwrap_or(0.0) as usize == index)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut next_id: u64 = 1;
|
||||||
|
let mut new_id = || {
|
||||||
|
next_id += 1;
|
||||||
|
next_id - 1
|
||||||
|
};
|
||||||
|
let mut pages = Vec::with_capacity(xpages.len());
|
||||||
|
for (index, xpage) in xpages.iter().enumerate() {
|
||||||
|
// Pages stored as [width, height] in very old files
|
||||||
|
let page_size = match xpage {
|
||||||
|
Bson::Array(wh) => glam::Vec2::new(
|
||||||
|
wh.first().and_then(Bson::as_f64).unwrap_or(1000.0) as f32,
|
||||||
|
wh.get(1).and_then(Bson::as_f64).unwrap_or(1400.0) as f32,
|
||||||
|
),
|
||||||
|
_ => glam::Vec2::new(
|
||||||
|
xpage.num("w").map_or(DEFAULT_PAGE.x, |w| w as f32),
|
||||||
|
xpage.num("h").map_or(DEFAULT_PAGE.y, |h| h as f32),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.max(glam::Vec2::ONE);
|
||||||
|
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||||
|
// aspect ratio, centred horizontally and aligned at the top
|
||||||
|
let size = crate::document::pdf_page_size(page_size.x, page_size.y);
|
||||||
|
let placement = crate::document::pdf_placement(page_size.x, page_size.y, size);
|
||||||
|
let scale = (placement[1].x - placement[0].x) / page_size.x;
|
||||||
|
let map = |p: glam::Vec2| placement[0] + p * scale;
|
||||||
|
|
||||||
|
let mut strokes: Vec<StrokeData> = Vec::new();
|
||||||
|
let mut pdf_background = None;
|
||||||
|
|
||||||
|
if let Some(image) = xpage.get("b") {
|
||||||
|
let natural = glam::Vec2::new(
|
||||||
|
image.num("nw").unwrap_or(0.0) as f32,
|
||||||
|
image.num("nh").unwrap_or(0.0) as f32,
|
||||||
|
);
|
||||||
|
let corners = if natural.min_element() > 0.0 {
|
||||||
|
crate::document::pdf_placement(natural.x, natural.y, size)
|
||||||
|
} else {
|
||||||
|
placement
|
||||||
|
};
|
||||||
|
let asset = image.num("a").map(|a| a as usize);
|
||||||
|
if image.get("e").and_then(Bson::as_str) == Some(".pdf") {
|
||||||
|
let pdf = asset.and_then(|a| {
|
||||||
|
pdfs.entry(a)
|
||||||
|
.or_insert_with(|| {
|
||||||
|
let bytes = (source.assets)(a)?;
|
||||||
|
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
|
||||||
|
let asset = assets.import_pdf(&bytes).ok()?;
|
||||||
|
Some(PdfSource { asset, sizes })
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
});
|
||||||
|
let page = image.num("pdfi").unwrap_or(0.0) as u32;
|
||||||
|
match pdf.and_then(|pdf| Some((pdf, *pdf.sizes.get(page as usize)?))) {
|
||||||
|
Some((pdf, (width_pt, height_pt))) => {
|
||||||
|
pdf_background = Some(PdfBackground {
|
||||||
|
asset: pdf.asset.clone(),
|
||||||
|
page,
|
||||||
|
width_pt,
|
||||||
|
height_pt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match image_asset(image, source, &assets, corners) {
|
||||||
|
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let images = xpage.get("i").map(Bson::as_array).unwrap_or_default();
|
||||||
|
for image in images.iter().chain(old_items("i", index)) {
|
||||||
|
let rect = ["x", "y", "w", "h"].map(|k| image.num(k).unwrap_or(0.0) as f32);
|
||||||
|
let corners = [
|
||||||
|
glam::Vec2::new(rect[0], rect[1]),
|
||||||
|
glam::Vec2::new(rect[0] + rect[2], rect[1]),
|
||||||
|
glam::Vec2::new(rect[0] + rect[2], rect[1] + rect[3]),
|
||||||
|
glam::Vec2::new(rect[0], rect[1] + rect[3]),
|
||||||
|
]
|
||||||
|
.map(map);
|
||||||
|
match image_asset(image, source, &assets, corners) {
|
||||||
|
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(delta) = xpage.get("q") {
|
||||||
|
let text = quill_text(delta);
|
||||||
|
if !text.is_empty() {
|
||||||
|
// Saber's text area starts 1.2 lines down, half a line in from the sides
|
||||||
|
let origin = glam::Vec2::new(line_height * 0.5, line_height * 1.2);
|
||||||
|
strokes.push(import::text_stroke(
|
||||||
|
new_id(),
|
||||||
|
&text,
|
||||||
|
line_height * 0.8 * scale,
|
||||||
|
TextFont::Sans,
|
||||||
|
map(origin),
|
||||||
|
[glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
Some((page_size.x - line_height) * scale),
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_strokes = xpage.get("s").map(Bson::as_array).unwrap_or_default();
|
||||||
|
for stroke in page_strokes.iter().chain(old_items("s", index)) {
|
||||||
|
let tool = stroke
|
||||||
|
.get("ty")
|
||||||
|
.and_then(Bson::as_str)
|
||||||
|
.unwrap_or("fountainPen");
|
||||||
|
if tool == "Eraser" || tool == "LaserPointer" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = stroke.num("s").unwrap_or(10.0) as f32;
|
||||||
|
let thinning = stroke.num("t").unwrap_or(0.5) as f32;
|
||||||
|
let pressure_enabled = stroke.bool("pe").unwrap_or(true);
|
||||||
|
let (points, pressures): (Vec<glam::Vec2>, Vec<Option<f32>>) =
|
||||||
|
match stroke.get("shape").and_then(Bson::as_str) {
|
||||||
|
Some(shape) => shape_points(stroke, shape)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p, None))
|
||||||
|
.unzip(),
|
||||||
|
None => points(stroke).into_iter().unzip(),
|
||||||
|
};
|
||||||
|
if points.is_empty() {
|
||||||
|
report.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let widths: Vec<f32> = pressures
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let p = p
|
||||||
|
.filter(|_| pressure_enabled)
|
||||||
|
.unwrap_or(0.5)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
(size * (1.0 - thinning * (1.0 - 2.0 * p))).max(size * 0.05) * scale
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let (width, pressures) = import::fit_widths(&widths);
|
||||||
|
let points: Vec<glam::Vec2> = points.into_iter().map(map).collect();
|
||||||
|
strokes.push(import::ink_stroke(
|
||||||
|
new_id(),
|
||||||
|
argb(stroke.get("c")),
|
||||||
|
tool == "Highlighter",
|
||||||
|
&points,
|
||||||
|
width,
|
||||||
|
&pressures,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(import::page(
|
||||||
|
index as u64 + 1,
|
||||||
|
size,
|
||||||
|
background,
|
||||||
|
pdf_background,
|
||||||
|
strokes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Imported {
|
||||||
|
pages,
|
||||||
|
next_stroke_id: next_id,
|
||||||
|
layout: None,
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the image of `image` (asset file, inline bytes or SVG text) in our asset folder.
|
||||||
|
fn image_asset(
|
||||||
|
image: &Bson,
|
||||||
|
source: &Source,
|
||||||
|
assets: &crate::assets::AssetStore,
|
||||||
|
corners: [glam::Vec2; 4],
|
||||||
|
) -> Option<String> {
|
||||||
|
let bytes = match (image.num("a"), image.get("b")) {
|
||||||
|
(Some(a), _) => (source.assets)(a as usize)?,
|
||||||
|
(None, Some(Bson::Binary(b))) => b.clone(),
|
||||||
|
(None, Some(Bson::Array(b))) => b.iter().filter_map(|v| Some(v.as_i64()? as u8)).collect(),
|
||||||
|
(None, Some(Bson::Str(svg))) => svg.clone().into_bytes(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if image.get("e").and_then(Bson::as_str) == Some(".svg") {
|
||||||
|
let size = glam::Vec2::new(
|
||||||
|
corners[0].distance(corners[1]),
|
||||||
|
corners[0].distance(corners[3]),
|
||||||
|
);
|
||||||
|
let (w, h, pixels) = import::rasterize_svg(std::str::from_utf8(&bytes).ok()?, size)?;
|
||||||
|
return assets.import_rgba(w, h, pixels).ok().map(|i| i.asset);
|
||||||
|
}
|
||||||
|
assets.import_bytes(&bytes).ok().map(|i| i.asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StrokeTool;
|
||||||
|
|
||||||
|
/// BSON encoder for test files.
|
||||||
|
fn encode(value: &Bson) -> Vec<u8> {
|
||||||
|
fn doc(fields: &[(String, Bson)]) -> Vec<u8> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
for (key, value) in fields {
|
||||||
|
let (kind, data): (u8, Vec<u8>) = match value {
|
||||||
|
Bson::Null => (0x0A, Vec::new()),
|
||||||
|
Bson::Bool(b) => (0x08, vec![*b as u8]),
|
||||||
|
Bson::Int(i) => (0x12, i.to_le_bytes().to_vec()),
|
||||||
|
Bson::Double(d) => (0x01, d.to_le_bytes().to_vec()),
|
||||||
|
Bson::Str(s) => {
|
||||||
|
let mut d = ((s.len() + 1) as i32).to_le_bytes().to_vec();
|
||||||
|
d.extend(s.as_bytes());
|
||||||
|
d.push(0);
|
||||||
|
(0x02, d)
|
||||||
|
}
|
||||||
|
Bson::Binary(b) => {
|
||||||
|
let mut d = (b.len() as i32).to_le_bytes().to_vec();
|
||||||
|
d.push(0);
|
||||||
|
d.extend(b);
|
||||||
|
(0x05, d)
|
||||||
|
}
|
||||||
|
Bson::Array(items) => {
|
||||||
|
let fields: Vec<(String, Bson)> = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| (i.to_string(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
(0x04, doc(&fields))
|
||||||
|
}
|
||||||
|
Bson::Doc(fields) => (0x03, doc(fields)),
|
||||||
|
};
|
||||||
|
body.push(kind);
|
||||||
|
body.extend(key.as_bytes());
|
||||||
|
body.push(0);
|
||||||
|
body.extend(data);
|
||||||
|
}
|
||||||
|
let mut out = ((body.len() + 5) as i32).to_le_bytes().to_vec();
|
||||||
|
out.extend(body);
|
||||||
|
out.push(0);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
match value {
|
||||||
|
Bson::Doc(fields) => doc(fields),
|
||||||
|
_ => panic!("only documents"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn d(fields: &[(&str, Bson)]) -> Bson {
|
||||||
|
Bson::Doc(
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn point(x: f32, y: f32, p: f32) -> Bson {
|
||||||
|
Bson::Binary([x, y, p].iter().flat_map(|v| v.to_le_bytes()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png() -> Vec<u8> {
|
||||||
|
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
png
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version 19 document: page 1 with ink, highlighter, a circle, an image and text;
|
||||||
|
/// page 2 with a PDF page as background. Assets: 0 = PNG, 1 = PDF.
|
||||||
|
fn sample() -> Bson {
|
||||||
|
let pen = d(&[
|
||||||
|
(
|
||||||
|
"p",
|
||||||
|
Bson::Array(vec![point(100.0, 100.0, 1.0), point(200.0, 100.0, 0.25)]),
|
||||||
|
),
|
||||||
|
("ty", Bson::Str("fountainPen".into())),
|
||||||
|
("pe", Bson::Bool(true)),
|
||||||
|
("c", Bson::Int(0xff0000ff)),
|
||||||
|
("s", Bson::Double(10.0)),
|
||||||
|
("t", Bson::Double(0.5)),
|
||||||
|
("ox", Bson::Double(10.0)),
|
||||||
|
]);
|
||||||
|
let marker = d(&[
|
||||||
|
(
|
||||||
|
"p",
|
||||||
|
Bson::Array(vec![point(0.0, 300.0, 0.5), point(300.0, 300.0, 0.5)]),
|
||||||
|
),
|
||||||
|
("ty", Bson::Str("Highlighter".into())),
|
||||||
|
("c", Bson::Int(0x80ffff00)),
|
||||||
|
("s", Bson::Double(40.0)),
|
||||||
|
]);
|
||||||
|
let circle = d(&[
|
||||||
|
("shape", Bson::Str("circle".into())),
|
||||||
|
("cx", Bson::Double(500.0)),
|
||||||
|
("cy", Bson::Double(500.0)),
|
||||||
|
("r", Bson::Double(50.0)),
|
||||||
|
("c", Bson::Int(0xff00ff00)),
|
||||||
|
("s", Bson::Double(5.0)),
|
||||||
|
]);
|
||||||
|
let image = d(&[
|
||||||
|
("e", Bson::Str(".png".into())),
|
||||||
|
("a", Bson::Int(0)),
|
||||||
|
("x", Bson::Double(600.0)),
|
||||||
|
("y", Bson::Double(100.0)),
|
||||||
|
("w", Bson::Double(200.0)),
|
||||||
|
("h", Bson::Double(100.0)),
|
||||||
|
]);
|
||||||
|
let text = Bson::Array(vec![
|
||||||
|
d(&[("insert", Bson::Str("Hallo ".into()))]),
|
||||||
|
d(&[("insert", Bson::Str("Welt\n".into()))]),
|
||||||
|
]);
|
||||||
|
let page1 = d(&[
|
||||||
|
("w", Bson::Double(1000.0)),
|
||||||
|
("h", Bson::Double(1400.0)),
|
||||||
|
("s", Bson::Array(vec![pen, marker, circle])),
|
||||||
|
("i", Bson::Array(vec![image])),
|
||||||
|
("q", text),
|
||||||
|
]);
|
||||||
|
let pdf = d(&[
|
||||||
|
("e", Bson::Str(".pdf".into())),
|
||||||
|
("a", Bson::Int(1)),
|
||||||
|
("pdfi", Bson::Int(1)),
|
||||||
|
("nw", Bson::Double(842.0)),
|
||||||
|
("nh", Bson::Double(595.0)),
|
||||||
|
]);
|
||||||
|
let page2 = d(&[
|
||||||
|
("w", Bson::Double(1000.0)),
|
||||||
|
("h", Bson::Double(707.0)),
|
||||||
|
("b", pdf),
|
||||||
|
]);
|
||||||
|
d(&[
|
||||||
|
("v", Bson::Int(19)),
|
||||||
|
("p", Bson::Str("dots".into())),
|
||||||
|
("l", Bson::Int(40)),
|
||||||
|
("z", Bson::Array(vec![page1, page2])),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_pdf() -> Vec<u8> {
|
||||||
|
crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(notebook: &Notebook, report: &ImportReport) {
|
||||||
|
assert_eq!(*report, ImportReport::default());
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
let first = ¬ebook.pages[0];
|
||||||
|
assert_eq!(first.background, PageBackground::DotGrid);
|
||||||
|
// Image, text, then the strokes
|
||||||
|
assert_eq!(first.strokes.len(), 5);
|
||||||
|
assert!(first.strokes[0].is_image());
|
||||||
|
assert_eq!(
|
||||||
|
first.strokes[1].text.as_ref().unwrap().content,
|
||||||
|
"Hallo Welt"
|
||||||
|
);
|
||||||
|
let pen = &first.strokes[2];
|
||||||
|
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||||
|
assert_eq!(pen.color, [0.0, 0.0, 1.0, 1.0]);
|
||||||
|
// 1000 Saber units = 210 mm; the offset moves the stroke by 10
|
||||||
|
assert!((pen.points[0].position.x - 231.0).abs() < 0.1);
|
||||||
|
// perfect_freehand: size 10, thinning 0.5 gives 15 at full pressure and 7.5 at a
|
||||||
|
// quarter, scaled by 2.1
|
||||||
|
let drawn: Vec<f32> = pen
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.map(|s| pen.width * (0.5 + s.pressure))
|
||||||
|
.collect();
|
||||||
|
assert!((drawn[0] - 31.5).abs() < 0.01 && (drawn[1] - 15.75).abs() < 0.01);
|
||||||
|
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
|
||||||
|
assert!((first.strokes[3].color[3] - 128.0 / 255.0).abs() < 1e-3);
|
||||||
|
assert_eq!(first.strokes[4].points.len(), 65);
|
||||||
|
let bg = notebook.pages[1].pdf_background.as_ref().unwrap();
|
||||||
|
assert_eq!(bg.page, 1);
|
||||||
|
assert!(notebook.pages[1].size.x > notebook.pages[1].size.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_bson() {
|
||||||
|
let value = sample();
|
||||||
|
assert_eq!(parse_bson(&encode(&value)).unwrap(), value);
|
||||||
|
assert!(parse_bson(b"\x05\x00\x00").is_err());
|
||||||
|
let mut broken = encode(&value);
|
||||||
|
broken.truncate(broken.len() - 3);
|
||||||
|
assert!(parse_bson(&broken).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_sbn2_with_assets_next_to_it() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = source_dir.path().join("Mathe.sbn2");
|
||||||
|
std::fs::write(&path, encode(&sample())).unwrap();
|
||||||
|
std::fs::write(source_dir.path().join("Mathe.sbn2.0"), png()).unwrap();
|
||||||
|
std::fs::write(source_dir.path().join("Mathe.sbn2.1"), sample_pdf()).unwrap();
|
||||||
|
let (notebook, _, dir, report) =
|
||||||
|
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
check(¬ebook, &report);
|
||||||
|
// Saved and loaded again
|
||||||
|
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
|
||||||
|
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||||
|
check(&reloaded, &report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zip archive with stored (uncompressed) files.
|
||||||
|
fn zip(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut central = Vec::new();
|
||||||
|
for (name, data) in files {
|
||||||
|
let offset = out.len() as u32;
|
||||||
|
let header = |sig: &[u8], central: bool| {
|
||||||
|
let mut h = sig.to_vec();
|
||||||
|
if central {
|
||||||
|
h.extend([20, 0]);
|
||||||
|
}
|
||||||
|
h.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
h.extend((data.len() as u32).to_le_bytes());
|
||||||
|
h.extend((data.len() as u32).to_le_bytes());
|
||||||
|
h.extend((name.len() as u16).to_le_bytes());
|
||||||
|
h.extend([0, 0]);
|
||||||
|
if central {
|
||||||
|
h.extend([0; 10]);
|
||||||
|
h.extend(offset.to_le_bytes());
|
||||||
|
}
|
||||||
|
h.extend(name.as_bytes());
|
||||||
|
h
|
||||||
|
};
|
||||||
|
out.extend(header(&[0x50, 0x4b, 0x03, 0x04], false));
|
||||||
|
out.extend(data);
|
||||||
|
central.extend(header(&[0x50, 0x4b, 0x01, 0x02], true));
|
||||||
|
}
|
||||||
|
let start = out.len() as u32;
|
||||||
|
out.extend(¢ral);
|
||||||
|
out.extend([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
|
||||||
|
out.extend((files.len() as u16).to_le_bytes());
|
||||||
|
out.extend((files.len() as u16).to_le_bytes());
|
||||||
|
out.extend((central.len() as u32).to_le_bytes());
|
||||||
|
out.extend(start.to_le_bytes());
|
||||||
|
out.extend([0, 0]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_sba_archive() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = source_dir.path().join("Mathe.sba");
|
||||||
|
let archive = zip(&[
|
||||||
|
("main.sbn2", encode(&sample())),
|
||||||
|
("main.sbn2.0", png()),
|
||||||
|
("main.sbn2.1", sample_pdf()),
|
||||||
|
]);
|
||||||
|
std::fs::write(&path, archive).unwrap();
|
||||||
|
let (notebook, _, _, report) =
|
||||||
|
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
check(¬ebook, &report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_old_json_files() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
// Version 7: strokes outside the pages, points as {x, y, p}
|
||||||
|
let path = source_dir.path().join("Alt.sbn");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"v": 7, "z": [{"w": 1000, "h": 1400}, {"w": 1000, "h": 1400}],
|
||||||
|
"s": [{"i": 1, "p": [{"x": 0, "y": 0, "p": 0.5}, {"x": 100, "y": 0, "p": 0.5}]}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (notebook, _, _, _) =
|
||||||
|
create_notebook_from_saber(temp_dir.path(), "Alt", &path).unwrap();
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 0);
|
||||||
|
assert_eq!(notebook.pages[1].strokes.len(), 1);
|
||||||
|
|
||||||
|
// The very first format: just a list of strokes
|
||||||
|
let path = source_dir.path().join("Uralt.sbn");
|
||||||
|
std::fs::write(&path, "[]").unwrap();
|
||||||
|
assert!(create_notebook_from_saber(temp_dir.path(), "Uralt", &path).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -338,7 +338,7 @@ pub enum UiAction {
|
|||||||
/// Bilddatei auf der aktuellen Seite einfügen
|
/// Bilddatei auf der aktuellen Seite einfügen
|
||||||
InsertImageFile(std::path::PathBuf),
|
InsertImageFile(std::path::PathBuf),
|
||||||
/// PDF als neues Notizbuch importieren
|
/// PDF als neues Notizbuch importieren
|
||||||
ImportPdf(std::path::PathBuf),
|
ImportFile(std::path::PathBuf),
|
||||||
/// Aktuelle Seite als Bild oder SVG exportieren
|
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||||
ExportPage(PageExportKind),
|
ExportPage(PageExportKind),
|
||||||
/// Titel einer Seite setzen (leer = entfernen)
|
/// Titel einer Seite setzen (leer = entfernen)
|
||||||
@@ -669,9 +669,9 @@ impl UiState {
|
|||||||
self.open_file_picker(PickerKind::Image);
|
self.open_file_picker(PickerKind::Image);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
|
/// Opens the file picker "Import" (PDF, Xournal++, Rnote, Saber) in the last used folder.
|
||||||
pub fn open_pdf_picker(&mut self) {
|
pub fn open_import_picker(&mut self) {
|
||||||
self.open_file_picker(PickerKind::Pdf);
|
self.open_file_picker(PickerKind::Import);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||||
@@ -2827,14 +2827,16 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
|||||||
t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
|
t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
|
||||||
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
||||||
),
|
),
|
||||||
PickerKind::Pdf => (
|
PickerKind::Import => (
|
||||||
t!("PDF importieren"),
|
t!("Importieren"),
|
||||||
t!("📄 PDF importieren"),
|
t!("📄 Importieren"),
|
||||||
"📄",
|
"📄",
|
||||||
t!("Keine Ordner oder PDF-Dateien"),
|
t!("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)"),
|
||||||
t!(
|
t!(
|
||||||
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
|
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
|
||||||
Beim Export liegen deine Notizen über den Originalseiten."
|
beim Export liegen deine Notizen über den Originalseiten. \
|
||||||
|
Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, \
|
||||||
|
Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -2924,7 +2926,7 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
|||||||
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||||
actions.push(match picker_kind {
|
actions.push(match picker_kind {
|
||||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||||
PickerKind::Pdf => UiAction::ImportPdf(path),
|
PickerKind::Import => UiAction::ImportFile(path),
|
||||||
});
|
});
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
@@ -4195,11 +4197,11 @@ pub fn render_ui(
|
|||||||
state.open_image_picker();
|
state.open_image_picker();
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …")))
|
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote, Saber) …")))
|
||||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben"))
|
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
state.open_pdf_picker();
|
state.open_import_picker();
|
||||||
}
|
}
|
||||||
|
|
||||||
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
||||||
|
|||||||
+708
@@ -0,0 +1,708 @@
|
|||||||
|
//! Import of Xournal++ (.xopp) and Xournal (.xoj) notebooks (Issue #47).
|
||||||
|
//!
|
||||||
|
//! Both formats are gzip-compressed XML: `<page>` elements with a `<background>` and
|
||||||
|
//! `<layer>`s holding `<stroke>`, `<text>` and `<image>` elements; coordinates are PDF points.
|
||||||
|
//! The import creates a new notebook: one page per Xournal page, all layers flattened in
|
||||||
|
//! order. PDF backgrounds are taken over if the PDF can still be found; LaTeX formulas
|
||||||
|
//! (`<teximage>`) have no counterpart and are skipped.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use quick_xml::events::{BytesStart, Event};
|
||||||
|
use quick_xml::XmlVersion;
|
||||||
|
|
||||||
|
use crate::document::{Notebook, PageBackground, PdfBackground};
|
||||||
|
use crate::import::{self, ImportReport, Imported};
|
||||||
|
use crate::storage::{NotebookManifest, StrokeData};
|
||||||
|
use crate::t;
|
||||||
|
use crate::text::TextFont;
|
||||||
|
|
||||||
|
/// Is the file by its name a Xournal++ or Xournal notebook?
|
||||||
|
pub fn is_xopp_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(|e| e.eq_ignore_ascii_case("xopp") || e.eq_ignore_ascii_case("xoj"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed Xournal document, still in PDF points.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct XoppDocument {
|
||||||
|
pub pages: Vec<XoppPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct XoppPage {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub background: XoppBackground,
|
||||||
|
pub items: Vec<XoppItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub enum XoppBackground {
|
||||||
|
#[default]
|
||||||
|
Plain,
|
||||||
|
Lined,
|
||||||
|
Graph,
|
||||||
|
Dotted,
|
||||||
|
/// Page `page` (from 0) of a PDF; `file` is only given on the first PDF page
|
||||||
|
Pdf {
|
||||||
|
file: Option<String>,
|
||||||
|
page: u32,
|
||||||
|
},
|
||||||
|
/// Image file as background
|
||||||
|
Pixmap {
|
||||||
|
file: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum XoppItem {
|
||||||
|
Stroke {
|
||||||
|
highlighter: bool,
|
||||||
|
color: [f32; 4],
|
||||||
|
/// Nominal width, then optionally one width per point (or per segment in Xournal)
|
||||||
|
widths: Vec<f32>,
|
||||||
|
points: Vec<glam::Vec2>,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
mono: bool,
|
||||||
|
size: f32,
|
||||||
|
pos: glam::Vec2,
|
||||||
|
color: [f32; 4],
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
/// Embedded image (base64 PNG) with its rectangle (left, top, right, bottom)
|
||||||
|
Image { rect: [f32; 4], data: String },
|
||||||
|
/// Element without a counterpart (LaTeX formula)
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the (usually gzip-compressed) XML of a Xournal file.
|
||||||
|
pub fn decompress(bytes: &[u8]) -> Result<String, String> {
|
||||||
|
String::from_utf8(import::gunzip(bytes)?).map_err(|_| t!("Keine Xournal-Datei").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attributes(e: &BytesStart) -> HashMap<String, String> {
|
||||||
|
e.attributes()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|a| {
|
||||||
|
let value = a.normalized_value(XmlVersion::Implicit1_0).ok()?;
|
||||||
|
Some((
|
||||||
|
String::from_utf8_lossy(a.key.as_ref()).into_owned(),
|
||||||
|
value.into_owned(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(attrs: &HashMap<String, String>, key: &str) -> Option<f32> {
|
||||||
|
attrs
|
||||||
|
.get(key)?
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xournal colors: `#rrggbbaa` or one of the names of the original Xournal palette.
|
||||||
|
fn parse_color(value: &str) -> [f32; 4] {
|
||||||
|
let rgba: u32 = match value.trim() {
|
||||||
|
"black" => 0x000000ff,
|
||||||
|
"blue" => 0x3333ccff,
|
||||||
|
"red" => 0xff0000ff,
|
||||||
|
"green" => 0x008000ff,
|
||||||
|
"gray" => 0x808080ff,
|
||||||
|
"lightblue" => 0x00c0ffff,
|
||||||
|
"lightgreen" => 0x00ff00ff,
|
||||||
|
"magenta" => 0xff00ffff,
|
||||||
|
"orange" => 0xff8000ff,
|
||||||
|
"yellow" => 0xffff00ff,
|
||||||
|
"white" => 0xffffffff,
|
||||||
|
hex => {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
match (u32::from_str_radix(hex, 16), hex.len()) {
|
||||||
|
(Ok(v), 8) => v,
|
||||||
|
(Ok(v), 6) => (v << 8) | 0xff,
|
||||||
|
_ => 0x000000ff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
[
|
||||||
|
(rgba >> 24) as f32 / 255.0,
|
||||||
|
((rgba >> 16) & 0xff) as f32 / 255.0,
|
||||||
|
((rgba >> 8) & 0xff) as f32 / 255.0,
|
||||||
|
(rgba & 0xff) as f32 / 255.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numbers(text: &str) -> Vec<f32> {
|
||||||
|
text.split_ascii_whitespace()
|
||||||
|
.filter_map(|v| v.parse::<f32>().ok())
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Element whose text content is still being collected.
|
||||||
|
enum Open {
|
||||||
|
Stroke(HashMap<String, String>),
|
||||||
|
Text(HashMap<String, String>),
|
||||||
|
Image(HashMap<String, String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the XML of a Xournal file.
|
||||||
|
pub fn parse(xml: &str) -> Result<XoppDocument, String> {
|
||||||
|
let mut reader = quick_xml::Reader::from_str(xml);
|
||||||
|
let mut doc = XoppDocument::default();
|
||||||
|
let mut seen_root = false;
|
||||||
|
let mut open: Option<Open> = None;
|
||||||
|
let mut content = String::new();
|
||||||
|
loop {
|
||||||
|
let event = reader
|
||||||
|
.read_event()
|
||||||
|
.map_err(|e| t!("Datei beschädigt: {err}", err = e))?;
|
||||||
|
match event {
|
||||||
|
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||||
|
let is_start = matches!(event, Event::Start(_));
|
||||||
|
let attrs = attributes(e);
|
||||||
|
match e.name().as_ref() {
|
||||||
|
b"xournal" => seen_root = true,
|
||||||
|
b"page" => doc.pages.push(XoppPage {
|
||||||
|
width: number(&attrs, "width").unwrap_or(595.0).max(1.0),
|
||||||
|
height: number(&attrs, "height").unwrap_or(842.0).max(1.0),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
b"background" => {
|
||||||
|
if let Some(page) = doc.pages.last_mut() {
|
||||||
|
page.background = parse_background(&attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b"stroke" if is_start => open = Some(Open::Stroke(attrs)),
|
||||||
|
b"text" if is_start => open = Some(Open::Text(attrs)),
|
||||||
|
b"image" if is_start => open = Some(Open::Image(attrs)),
|
||||||
|
b"teximage" => {
|
||||||
|
if let Some(page) = doc.pages.last_mut() {
|
||||||
|
page.items.push(XoppItem::Unsupported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
content.clear();
|
||||||
|
}
|
||||||
|
Event::Text(t) if open.is_some() => {
|
||||||
|
content.push_str(&t.decode().map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
Event::CData(t) if open.is_some() => {
|
||||||
|
content.push_str(&t.decode().map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
Event::GeneralRef(r) if open.is_some() => {
|
||||||
|
if let Ok(Some(c)) = r.resolve_char_ref() {
|
||||||
|
content.push(c);
|
||||||
|
} else if let Ok(name) = r.decode() {
|
||||||
|
if let Some(s) = quick_xml::escape::resolve_predefined_entity(&name) {
|
||||||
|
content.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(_) => {
|
||||||
|
if let (Some(element), Some(page)) = (open.take(), doc.pages.last_mut()) {
|
||||||
|
page.items.extend(finish_item(element, &content));
|
||||||
|
}
|
||||||
|
content.clear();
|
||||||
|
}
|
||||||
|
Event::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen_root {
|
||||||
|
return Err(t!("Keine Xournal-Datei").to_string());
|
||||||
|
}
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_background(attrs: &HashMap<String, String>) -> XoppBackground {
|
||||||
|
match attrs.get("type").map(String::as_str) {
|
||||||
|
Some("pdf") => {
|
||||||
|
// Xournal++ has written page numbers like "2ll"; only the digits count
|
||||||
|
let page = attrs
|
||||||
|
.get("pageno")
|
||||||
|
.map(|v| {
|
||||||
|
v.chars()
|
||||||
|
.take_while(char::is_ascii_digit)
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(1);
|
||||||
|
XoppBackground::Pdf {
|
||||||
|
file: attrs.get("filename").filter(|f| !f.is_empty()).cloned(),
|
||||||
|
page: page.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("pixmap") => match attrs.get("filename") {
|
||||||
|
Some(file) if !file.is_empty() => XoppBackground::Pixmap { file: file.clone() },
|
||||||
|
_ => XoppBackground::Plain,
|
||||||
|
},
|
||||||
|
_ => match attrs.get("style").map(String::as_str) {
|
||||||
|
Some("lined" | "ruled") => XoppBackground::Lined,
|
||||||
|
Some("graph" | "isograph") => XoppBackground::Graph,
|
||||||
|
Some("dotted" | "isodotted") => XoppBackground::Dotted,
|
||||||
|
_ => XoppBackground::Plain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_item(element: Open, content: &str) -> Option<XoppItem> {
|
||||||
|
match element {
|
||||||
|
Open::Stroke(attrs) => {
|
||||||
|
let coords = numbers(content);
|
||||||
|
let points: Vec<glam::Vec2> = coords
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&[x, y]| glam::Vec2::new(x, y))
|
||||||
|
.collect();
|
||||||
|
if points.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(XoppItem::Stroke {
|
||||||
|
highlighter: attrs.get("tool").is_some_and(|t| t == "highlighter"),
|
||||||
|
color: parse_color(attrs.get("color").map_or("black", String::as_str)),
|
||||||
|
widths: attrs.get("width").map(|w| numbers(w)).unwrap_or_default(),
|
||||||
|
points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Open::Text(attrs) => {
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(XoppItem::Text {
|
||||||
|
mono: attrs
|
||||||
|
.get("font")
|
||||||
|
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
|
||||||
|
size: number(&attrs, "size").unwrap_or(12.0).max(1.0),
|
||||||
|
pos: glam::Vec2::new(
|
||||||
|
number(&attrs, "x").unwrap_or(0.0),
|
||||||
|
number(&attrs, "y").unwrap_or(0.0),
|
||||||
|
),
|
||||||
|
color: parse_color(attrs.get("color").map_or("black", String::as_str)),
|
||||||
|
content: content.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Open::Image(attrs) => {
|
||||||
|
let rect = ["left", "top", "right", "bottom"].map(|k| number(&attrs, k));
|
||||||
|
match rect {
|
||||||
|
[Some(l), Some(t), Some(r), Some(b)] => Some(XoppItem::Image {
|
||||||
|
rect: [l, t, r, b],
|
||||||
|
data: content.to_string(),
|
||||||
|
}),
|
||||||
|
_ => Some(XoppItem::Unsupported),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = widths.get(1..).unwrap_or_default();
|
||||||
|
if per_point.is_empty() || per_point.len() + 1 < count {
|
||||||
|
return (nominal, vec![0.5; count]);
|
||||||
|
}
|
||||||
|
let scaled: Vec<f32> = (0..count)
|
||||||
|
.map(|i| per_point[i.min(per_point.len() - 1)] * scale)
|
||||||
|
.collect();
|
||||||
|
import::fit_widths(&scaled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a referenced file: as given, next to the Xournal file, or as Xournal++ attachment
|
||||||
|
/// (`<file>.xopp.<name>`).
|
||||||
|
fn find_file(name: &str, xopp_path: &Path) -> Option<PathBuf> {
|
||||||
|
let given = PathBuf::from(name);
|
||||||
|
let dir = xopp_path.parent().unwrap_or(Path::new("."));
|
||||||
|
let mut candidates = vec![given.clone(), dir.join(&given)];
|
||||||
|
candidates.push(PathBuf::from(format!("{}.{name}", xopp_path.display())));
|
||||||
|
if let Some(file_name) = given.file_name() {
|
||||||
|
candidates.push(dir.join(file_name));
|
||||||
|
}
|
||||||
|
candidates.into_iter().find(|p| p.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new notebook from a Xournal++/Xournal file.
|
||||||
|
pub fn create_notebook_from_xopp(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
xopp_path: &Path,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||||
|
let bytes = std::fs::read(xopp_path).map_err(|e| e.to_string())?;
|
||||||
|
let doc = parse(&decompress(&bytes)?)?;
|
||||||
|
if doc.pages.is_empty() {
|
||||||
|
return Err(t!("Die Datei enthält keine Seiten").to_string());
|
||||||
|
}
|
||||||
|
import::create_notebook(base_dir, title, |dir| convert(&doc, xopp_path, dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PDF used as background: asset name and page sizes in points.
|
||||||
|
struct PdfSource {
|
||||||
|
asset: String,
|
||||||
|
sizes: Vec<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(doc: &XoppDocument, xopp_path: &Path, notebook_dir: &Path) -> Result<Imported, String> {
|
||||||
|
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||||
|
let mut report = ImportReport::default();
|
||||||
|
let mut pdfs: HashMap<String, Option<PdfSource>> = HashMap::new();
|
||||||
|
let mut current_pdf: Option<String> = None;
|
||||||
|
let mut next_id: u64 = 1;
|
||||||
|
let mut pages = Vec::with_capacity(doc.pages.len());
|
||||||
|
|
||||||
|
for (index, xpage) in doc.pages.iter().enumerate() {
|
||||||
|
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||||
|
// aspect ratio, centred horizontally and aligned at the top
|
||||||
|
let size = crate::document::pdf_page_size(xpage.width, xpage.height);
|
||||||
|
let placement = crate::document::pdf_placement(xpage.width, xpage.height, size);
|
||||||
|
let [origin, top_right, _, bottom_left] = placement;
|
||||||
|
let scale =
|
||||||
|
((top_right.x - origin.x) / xpage.width).min((bottom_left.y - origin.y) / xpage.height);
|
||||||
|
let map = |p: glam::Vec2| origin + p * scale;
|
||||||
|
|
||||||
|
let mut background = PageBackground::Blank;
|
||||||
|
let mut pdf_background = None;
|
||||||
|
let mut strokes: Vec<StrokeData> = Vec::new();
|
||||||
|
let mut new_id = || {
|
||||||
|
next_id += 1;
|
||||||
|
next_id - 1
|
||||||
|
};
|
||||||
|
|
||||||
|
match &xpage.background {
|
||||||
|
XoppBackground::Plain => {}
|
||||||
|
XoppBackground::Lined => background = PageBackground::Lined,
|
||||||
|
XoppBackground::Graph => background = PageBackground::Grid5mm,
|
||||||
|
XoppBackground::Dotted => background = PageBackground::DotGrid,
|
||||||
|
XoppBackground::Pdf { file, page } => {
|
||||||
|
if let Some(file) = file {
|
||||||
|
current_pdf = Some(file.clone());
|
||||||
|
}
|
||||||
|
if let Some(file) = ¤t_pdf {
|
||||||
|
let source = pdfs
|
||||||
|
.entry(file.clone())
|
||||||
|
.or_insert_with(|| load_pdf(file, xopp_path, &assets));
|
||||||
|
match source {
|
||||||
|
Some(source) => {
|
||||||
|
if let Some(&(width_pt, height_pt)) = source.sizes.get(*page as usize) {
|
||||||
|
pdf_background = Some(PdfBackground {
|
||||||
|
asset: source.asset.clone(),
|
||||||
|
page: *page,
|
||||||
|
width_pt,
|
||||||
|
height_pt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => report.missing_pdf = Some(file.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XoppBackground::Pixmap { file } => {
|
||||||
|
let image = find_file(file, xopp_path)
|
||||||
|
.and_then(|p| std::fs::read(p).ok())
|
||||||
|
.and_then(|b| assets.import_bytes(&b).ok());
|
||||||
|
match image {
|
||||||
|
Some(image) => {
|
||||||
|
strokes.push(import::image_stroke(new_id(), image.asset, placement))
|
||||||
|
}
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &xpage.items {
|
||||||
|
match item {
|
||||||
|
XoppItem::Stroke {
|
||||||
|
highlighter,
|
||||||
|
color,
|
||||||
|
widths,
|
||||||
|
points,
|
||||||
|
} => {
|
||||||
|
let (width, pressures) = stroke_widths(widths, points.len(), scale);
|
||||||
|
let points: Vec<glam::Vec2> = points.iter().map(|&p| map(p)).collect();
|
||||||
|
strokes.push(import::ink_stroke(
|
||||||
|
new_id(),
|
||||||
|
*color,
|
||||||
|
*highlighter,
|
||||||
|
&points,
|
||||||
|
width,
|
||||||
|
&pressures,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
XoppItem::Text {
|
||||||
|
mono,
|
||||||
|
size,
|
||||||
|
pos,
|
||||||
|
color,
|
||||||
|
content,
|
||||||
|
} => {
|
||||||
|
let font = if *mono {
|
||||||
|
TextFont::Mono
|
||||||
|
} else {
|
||||||
|
TextFont::Sans
|
||||||
|
};
|
||||||
|
// Xournal doesn't wrap: as wide as the longest line
|
||||||
|
strokes.push(import::text_stroke(
|
||||||
|
new_id(),
|
||||||
|
content,
|
||||||
|
size * scale,
|
||||||
|
font,
|
||||||
|
map(*pos),
|
||||||
|
[glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
None,
|
||||||
|
*color,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
XoppItem::Image { rect, data } => {
|
||||||
|
let compact: String =
|
||||||
|
data.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||||
|
let image = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(compact)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| assets.import_bytes(&b).ok());
|
||||||
|
match image {
|
||||||
|
Some(image) => {
|
||||||
|
let [l, t, r, b] = *rect;
|
||||||
|
let corners = [
|
||||||
|
glam::Vec2::new(l, t),
|
||||||
|
glam::Vec2::new(r, t),
|
||||||
|
glam::Vec2::new(r, b),
|
||||||
|
glam::Vec2::new(l, b),
|
||||||
|
]
|
||||||
|
.map(map);
|
||||||
|
strokes.push(import::image_stroke(new_id(), image.asset, corners));
|
||||||
|
}
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XoppItem::Unsupported => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(import::page(
|
||||||
|
index as u64 + 1,
|
||||||
|
size,
|
||||||
|
background,
|
||||||
|
pdf_background,
|
||||||
|
strokes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
let bytes = std::fs::read(find_file(file, xopp_path)?).ok()?;
|
||||||
|
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
|
||||||
|
let asset = assets.import_pdf(&bytes).ok()?;
|
||||||
|
Some(PdfSource { asset, sizes })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StrokeTool;
|
||||||
|
use crate::storage;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn gzip(xml: &str) -> Vec<u8> {
|
||||||
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
encoder.write_all(xml.as_bytes()).unwrap();
|
||||||
|
encoder.finish().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png_base64() -> String {
|
||||||
|
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE: &str = r##"<?xml version="1.0" standalone="no"?>
|
||||||
|
<xournal creator="Xournal++ 1.2.3" fileversion="4">
|
||||||
|
<title>Xournal++ document</title>
|
||||||
|
<page width="595.27559" height="841.88976">
|
||||||
|
<background type="solid" color="#ffffffff" style="lined"/>
|
||||||
|
<layer>
|
||||||
|
<stroke tool="pen" ts="0" fn="" color="#3333ccff" width="1.41 1.0 2.0 1.5" capStyle="round">10 20 30 40 50 60</stroke>
|
||||||
|
<stroke tool="highlighter" color="#ffff00ff" width="8.5">0 0 100 0</stroke>
|
||||||
|
</layer>
|
||||||
|
<layer>
|
||||||
|
<text font="Sans" size="12" x="72" y="100" color="#000000ff">Hallo & Welt
|
||||||
|
zweite Zeile</text>
|
||||||
|
<teximage text="x^2" left="0" top="0" right="10" bottom="10">AAAA</teximage>
|
||||||
|
</layer>
|
||||||
|
</page>
|
||||||
|
<page width="841.88976" height="595.27559">
|
||||||
|
<background type="pdf" domain="absolute" filename="/nirgends/skript.pdf" pageno="1ll"/>
|
||||||
|
<layer/>
|
||||||
|
</page>
|
||||||
|
<page width="841.88976" height="595.27559">
|
||||||
|
<background type="pdf" pageno="2ll"/>
|
||||||
|
<layer/>
|
||||||
|
</page>
|
||||||
|
</xournal>"##;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_xopp() {
|
||||||
|
let doc = parse(&decompress(&gzip(SAMPLE)).unwrap()).unwrap();
|
||||||
|
assert_eq!(doc.pages.len(), 3);
|
||||||
|
let first = &doc.pages[0];
|
||||||
|
assert_eq!(first.background, XoppBackground::Lined);
|
||||||
|
assert_eq!(first.items.len(), 4);
|
||||||
|
match &first.items[0] {
|
||||||
|
XoppItem::Stroke {
|
||||||
|
highlighter,
|
||||||
|
color,
|
||||||
|
widths,
|
||||||
|
points,
|
||||||
|
} => {
|
||||||
|
assert!(!highlighter);
|
||||||
|
assert_eq!(*color, [0.2, 0.2, 0.8, 1.0]);
|
||||||
|
assert_eq!(widths, &[1.41, 1.0, 2.0, 1.5]);
|
||||||
|
assert_eq!(points.len(), 3);
|
||||||
|
assert_eq!(points[2], glam::Vec2::new(50.0, 60.0));
|
||||||
|
}
|
||||||
|
other => panic!("expected a stroke, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
first.items[1],
|
||||||
|
XoppItem::Stroke {
|
||||||
|
highlighter: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
match &first.items[2] {
|
||||||
|
XoppItem::Text { content, size, .. } => {
|
||||||
|
assert_eq!(content, "Hallo & Welt\nzweite Zeile");
|
||||||
|
assert_eq!(*size, 12.0);
|
||||||
|
}
|
||||||
|
other => panic!("expected a text, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(first.items[3], XoppItem::Unsupported);
|
||||||
|
assert_eq!(
|
||||||
|
doc.pages[1].background,
|
||||||
|
XoppBackground::Pdf {
|
||||||
|
file: Some("/nirgends/skript.pdf".into()),
|
||||||
|
page: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
doc.pages[2].background,
|
||||||
|
XoppBackground::Pdf {
|
||||||
|
file: None,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rejects_other_files() {
|
||||||
|
assert!(parse("<svg></svg>").is_err());
|
||||||
|
assert!(decompress(&[0x1f, 0x8b, 1, 2, 3]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_colors() {
|
||||||
|
assert_eq!(parse_color("#ff000080")[3], 128.0 / 255.0);
|
||||||
|
assert_eq!(parse_color("red"), [1.0, 0.0, 0.0, 1.0]);
|
||||||
|
assert_eq!(parse_color("#00ff00"), [0.0, 1.0, 0.0, 1.0]);
|
||||||
|
assert_eq!(parse_color("Unsinn"), [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pressure_widths_map_to_our_width_model() {
|
||||||
|
// Without pressure: nominal width, medium pressure (drawn exactly that wide)
|
||||||
|
assert_eq!(stroke_widths(&[2.0], 3, 10.0), (20.0, vec![0.5; 3]));
|
||||||
|
// With pressure: the widest point is drawn exactly as wide as in Xournal++
|
||||||
|
let (width, pressures) = stroke_widths(&[1.0, 3.0, 1.5], 2, 10.0);
|
||||||
|
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
|
||||||
|
assert!((drawn[0] - 30.0).abs() < 1e-3);
|
||||||
|
assert!((drawn[1] - 15.0).abs() < 1e-3);
|
||||||
|
// Xournal: one width per segment, the last point repeats the last one
|
||||||
|
let (_, pressures) = stroke_widths(&[1.0, 2.0, 1.0], 3, 1.0);
|
||||||
|
assert_eq!(pressures.len(), 3);
|
||||||
|
assert_eq!(pressures[1], pressures[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_notebook_from_xopp() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)]);
|
||||||
|
std::fs::write(source_dir.path().join("skript.pdf"), &pdf).unwrap();
|
||||||
|
// The PDF moved next to the notebook; its image is embedded
|
||||||
|
let xml = SAMPLE.replace("<layer/>\n</page>\n</xournal>", &format!(
|
||||||
|
"<layer><image left=\"0\" top=\"0\" right=\"40\" bottom=\"20\">{}</image></layer>\n</page>\n</xournal>",
|
||||||
|
png_base64()
|
||||||
|
));
|
||||||
|
let path = source_dir.path().join("Mathe.xopp");
|
||||||
|
std::fs::write(&path, gzip(&xml)).unwrap();
|
||||||
|
|
||||||
|
let (notebook, manifest, dir, report) =
|
||||||
|
create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
assert_eq!(manifest.title, "Mathe");
|
||||||
|
assert_eq!(notebook.pages.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
report,
|
||||||
|
ImportReport {
|
||||||
|
skipped: 1,
|
||||||
|
missing_pdf: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mut reloaded, _) = storage::load_notebook(&dir).unwrap();
|
||||||
|
storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||||
|
let first = &reloaded.pages[0];
|
||||||
|
assert_eq!(first.background, PageBackground::Lined);
|
||||||
|
assert_eq!(first.size, crate::document::A4_SIZE);
|
||||||
|
assert_eq!(first.strokes.len(), 3);
|
||||||
|
let pen = &first.strokes[0];
|
||||||
|
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||||
|
// Points → page units (10 per mm): 10 pt = 35.3 units
|
||||||
|
assert!((pen.points[0].position.x - 35.28).abs() < 0.05);
|
||||||
|
assert!(!pen.vertices.is_empty());
|
||||||
|
let marker = &first.strokes[1];
|
||||||
|
assert_eq!(marker.tool, StrokeTool::Highlighter);
|
||||||
|
assert_eq!(marker.color[3], import::HIGHLIGHTER_ALPHA);
|
||||||
|
assert!(first.strokes[2].is_text());
|
||||||
|
|
||||||
|
for (i, page) in reloaded.pages[1..].iter().enumerate() {
|
||||||
|
let bg = page
|
||||||
|
.pdf_background
|
||||||
|
.as_ref()
|
||||||
|
.expect("PDF background missing");
|
||||||
|
assert_eq!(bg.page, i as u32);
|
||||||
|
assert!(page.size.x > page.size.y);
|
||||||
|
}
|
||||||
|
assert!(reloaded.pages[2].strokes[0].is_image());
|
||||||
|
// Stroke ids are unique and the notebook continues after them
|
||||||
|
assert!(reloaded.next_stroke_id > 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_missing_pdf_is_reported() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = source_dir.path().join("Mathe.xoj");
|
||||||
|
std::fs::write(&path, SAMPLE).unwrap();
|
||||||
|
let (notebook, _, _, report) =
|
||||||
|
create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
assert_eq!(notebook.pages.len(), 3);
|
||||||
|
assert_eq!(report.missing_pdf.as_deref(), Some("/nirgends/skript.pdf"));
|
||||||
|
assert!(notebook.pages[1].pdf_background.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user