Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6f64ec8e | ||
|
|
46026eb8e5 | ||
|
|
2bd697ef82 | ||
|
|
b5d9d72e79 | ||
|
|
955eaa4095 | ||
|
|
330c29193f | ||
|
|
dd515787ec | ||
|
|
b11fb2d762 |
No files matched your search
Generated
+134
-3
@@ -539,6 +539,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -790,6 +796,12 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1381,6 +1393,12 @@ dependencies = [
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -1393,14 +1411,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.34.1"
|
||||
version = "0.36.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"flate2",
|
||||
"fs2",
|
||||
"glam",
|
||||
"hayro",
|
||||
@@ -1411,6 +1431,8 @@ dependencies = [
|
||||
"octotablet",
|
||||
"pollster",
|
||||
"printpdf",
|
||||
"quick-xml",
|
||||
"resvg",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2079,6 +2101,12 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2326,6 +2354,33 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2340,6 +2395,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -2450,7 +2514,7 @@ dependencies = [
|
||||
"log",
|
||||
"memmap2",
|
||||
"smithay-client-toolkit",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2531,6 +2595,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "simplecss"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
@@ -2638,6 +2711,9 @@ name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
@@ -2667,6 +2743,16 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -2828,7 +2914,22 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2842,6 +2943,17 @@ dependencies = [
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -2978,6 +3090,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.49.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c"
|
||||
dependencies = [
|
||||
"data-url",
|
||||
"imagesize",
|
||||
"kurbo",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.34.1"
|
||||
version = "0.36.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
@@ -48,6 +48,13 @@ fs2 = "0.4"
|
||||
hayro = "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)
|
||||
ab_glyph = "0.2"
|
||||
|
||||
|
||||
+10
-4
@@ -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++ und Rnote importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||
|
||||
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
|
||||
@@ -47,7 +47,9 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
@@ -152,8 +154,9 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
||||
├── lib.rs # Bibliotheks-Exporte (für Tests)
|
||||
├── main.rs # Einstieg für den Desktop, startet app::run
|
||||
├── app.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
||||
├── lib.rs # Bibliotheks-Exporte (App und Module, auch für Tests)
|
||||
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
|
||||
├── shader.wgsl # Vertex- und Fragment-Shader
|
||||
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
|
||||
@@ -168,6 +171,9 @@ src/
|
||||
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
||||
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
||||
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
||||
├── import.rs # Import als neues Notizbuch (Gemeinsames)
|
||||
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
|
||||
├── rnote.rs # Import von Rnote-Notizbüchern
|
||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||
├── history.rs # Rückgängig/Wiederholen
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
---
|
||||
|
||||
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
|
||||
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++ and Rnote notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
|
||||
|
||||
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
|
||||
|
||||
@@ -47,7 +47,9 @@ 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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
@@ -154,8 +156,9 @@ New code comments and commit messages are written in English; older code and his
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.rs # Event loop (winit), mouse, touch and pen input, control flow
|
||||
├── lib.rs # Library exports (for tests)
|
||||
├── main.rs # Desktop entry point, starts app::run
|
||||
├── app.rs # Event loop (winit), mouse, touch and pen input, control flow
|
||||
├── lib.rs # Library exports (app and modules, also for tests)
|
||||
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
|
||||
├── shader.wgsl # Vertex and fragment shaders
|
||||
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
|
||||
@@ -170,6 +173,9 @@ src/
|
||||
├── assets.rs # Images and PDFs in the notebook folder
|
||||
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
||||
├── pdf_merge.rs # Underlaying imported PDF pages on export
|
||||
├── import.rs # Import as a new notebook (shared parts)
|
||||
├── xopp.rs # Import of Xournal++/Xournal notebooks
|
||||
├── rnote.rs # Import of Rnote notebooks
|
||||
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||
├── history.rs # Undo/redo
|
||||
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
||||
|
||||
@@ -15,7 +15,7 @@ ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thr
|
||||
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
|
||||
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
|
||||
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
|
||||
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
|
||||
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`app.rs`) |
|
||||
|
||||
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ ink-notes is a single program with an event loop (`winit`) on the main thread, p
|
||||
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
|
||||
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
|
||||
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
|
||||
| Export | PDF/PNG/SVG export, so the interface does not freeze (`main.rs`) |
|
||||
| Export | PDF/PNG/SVG export, so the interface does not freeze (`app.rs`) |
|
||||
|
||||
Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -84,6 +84,21 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<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">
|
||||
<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>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.34.1" date="2026-10-25">
|
||||
<description>
|
||||
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
|
||||
|
||||
@@ -805,6 +805,19 @@
|
||||
"dest": "cargo/vendor/d3d12-0.19.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/data-url/data-url-0.3.2.crate",
|
||||
"sha256": "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376",
|
||||
"dest": "cargo/vendor/data-url-0.3.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/data-url-0.3.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -1156,6 +1169,19 @@
|
||||
"dest": "cargo/vendor/flate2-1.1.10",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/float-cmp/float-cmp-0.9.0.crate",
|
||||
"sha256": "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4",
|
||||
"dest": "cargo/vendor/float-cmp-0.9.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/float-cmp-0.9.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -1884,6 +1910,19 @@
|
||||
"dest": "cargo/vendor/image-webp-0.2.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/imagesize/imagesize-0.15.0.crate",
|
||||
"sha256": "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a",
|
||||
"dest": "cargo/vendor/imagesize-0.15.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/imagesize-0.15.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -2794,6 +2833,19 @@
|
||||
"dest": "cargo/vendor/pic-scale-0.7.12",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/pico-args/pico-args-0.5.0.crate",
|
||||
"sha256": "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315",
|
||||
"dest": "cargo/vendor/pico-args-0.5.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/pico-args-0.5.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3197,6 +3249,32 @@
|
||||
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/resvg/resvg-0.49.0.crate",
|
||||
"sha256": "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8",
|
||||
"dest": "cargo/vendor/resvg-0.49.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/resvg-0.49.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/rgb/rgb-0.8.53.crate",
|
||||
"sha256": "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4",
|
||||
"dest": "cargo/vendor/rgb-0.8.53"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/rgb-0.8.53",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3210,6 +3288,19 @@
|
||||
"dest": "cargo/vendor/ring-0.17.14",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/roxmltree/roxmltree-0.21.2.crate",
|
||||
"sha256": "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907",
|
||||
"dest": "cargo/vendor/roxmltree-0.21.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/roxmltree-0.21.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3483,6 +3574,19 @@
|
||||
"dest": "cargo/vendor/simdutf8-0.1.5",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/simplecss/simplecss-0.2.2.crate",
|
||||
"sha256": "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c",
|
||||
"dest": "cargo/vendor/simplecss-0.2.2"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/simplecss-0.2.2",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3678,6 +3782,19 @@
|
||||
"dest": "cargo/vendor/subtle-2.6.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/svgtypes/svgtypes-0.16.1.crate",
|
||||
"sha256": "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d",
|
||||
"dest": "cargo/vendor/svgtypes-0.16.1"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/svgtypes-0.16.1",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3873,6 +3990,19 @@
|
||||
"dest": "cargo/vendor/tiny-skia-0.11.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.12.0.crate",
|
||||
"sha256": "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea",
|
||||
"dest": "cargo/vendor/tiny-skia-0.12.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tiny-skia-0.12.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -3886,6 +4016,19 @@
|
||||
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.12.0.crate",
|
||||
"sha256": "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9",
|
||||
"dest": "cargo/vendor/tiny-skia-path-0.12.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/tiny-skia-path-0.12.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
@@ -4094,6 +4237,19 @@
|
||||
"dest": "cargo/vendor/url-2.5.8",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
"url": "https://static.crates.io/crates/usvg/usvg-0.49.0.crate",
|
||||
"sha256": "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c",
|
||||
"dest": "cargo/vendor/usvg-0.49.0"
|
||||
},
|
||||
{
|
||||
"type": "inline",
|
||||
"contents": "{\"package\": \"d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c\", \"files\": {}}",
|
||||
"dest": "cargo/vendor/usvg-0.49.0",
|
||||
"dest-filename": ".cargo-checksum.json"
|
||||
},
|
||||
{
|
||||
"type": "archive",
|
||||
"archive-type": "tar-gzip",
|
||||
|
||||
@@ -84,6 +84,21 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<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">
|
||||
<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>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.34.1" date="2026-10-25">
|
||||
<description>
|
||||
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **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 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
|
||||
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
|
||||
|
||||
+4356
File diff suppressed because it is too large.
Load diff
+30
-15
@@ -1222,21 +1222,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||
|
||||
// Geschlossener Pfad um den Ribbon-Rand:
|
||||
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
||||
let mut ring = Vec::with_capacity(n * 2);
|
||||
|
||||
// 1. Linke Hüllkurve vorwärts
|
||||
for i in 0..n {
|
||||
let v = stroke.vertices[i * 2];
|
||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
||||
}
|
||||
|
||||
// 2. Rechte Hüllkurve rückwärts
|
||||
for i in (0..n).rev() {
|
||||
let v = stroke.vertices[i * 2 + 1];
|
||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
||||
}
|
||||
let outline: Vec<glam::Vec2> = (0..n)
|
||||
.map(|i| stroke.vertices[i * 2].position)
|
||||
.chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
|
||||
.map(glam::Vec2::from)
|
||||
.collect();
|
||||
// The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
|
||||
// tell apart; fewer points make the PDF, PNG export and copying as an image much faster
|
||||
let outline = if exact_outlines() {
|
||||
outline
|
||||
} else {
|
||||
crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
|
||||
};
|
||||
let ring: Vec<(Point, bool)> = outline
|
||||
.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(
|
||||
stroke.color[0],
|
||||
@@ -1254,6 +1258,17 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||
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.
|
||||
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||
if stroke.points.is_empty() {
|
||||
|
||||
+17
-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).
|
||||
//!
|
||||
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||
@@ -22,8 +22,8 @@ pub struct PickerEntry {
|
||||
pub enum PickerKind {
|
||||
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||
Image,
|
||||
/// PDF zum Importieren als neues Notizbuch
|
||||
Pdf,
|
||||
/// PDF, Xournal++ or Rnote file to import as a new notebook
|
||||
Import,
|
||||
}
|
||||
|
||||
impl PickerKind {
|
||||
@@ -31,7 +31,7 @@ impl PickerKind {
|
||||
pub fn accepts(self, path: &Path) -> bool {
|
||||
match self {
|
||||
PickerKind::Image => is_supported_image_path(path),
|
||||
PickerKind::Pdf => is_pdf_path(path),
|
||||
PickerKind::Import => crate::import::is_importable_path(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ impl FilePicker {
|
||||
.or_else(|| {
|
||||
let preferred = match kind {
|
||||
PickerKind::Image => tn!("🖼 Bilder"),
|
||||
PickerKind::Pdf => tn!("⬇ Downloads"),
|
||||
PickerKind::Import => tn!("⬇ Downloads"),
|
||||
};
|
||||
let all = shortcuts();
|
||||
all.iter()
|
||||
@@ -192,6 +192,8 @@ mod tests {
|
||||
".versteckt.png",
|
||||
"c.webp",
|
||||
"Skript.PDF",
|
||||
"mathe.xopp",
|
||||
"skizze.rnote",
|
||||
] {
|
||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||
}
|
||||
@@ -210,7 +212,16 @@ mod tests {
|
||||
names(PickerKind::Image),
|
||||
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
||||
);
|
||||
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
|
||||
assert_eq!(
|
||||
names(PickerKind::Import),
|
||||
[
|
||||
"aOrdner",
|
||||
"zOrdner",
|
||||
"mathe.xopp",
|
||||
"skizze.rnote",
|
||||
"Skript.PDF"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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`.
|
||||
#[inline]
|
||||
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||
@@ -2327,6 +2384,78 @@ pub fn shape_to_stroke(
|
||||
mod tests {
|
||||
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]
|
||||
fn test_detect_line() {
|
||||
// Leicht zittrige diagonale Punktfolge
|
||||
|
||||
+4
-1
@@ -156,7 +156,7 @@ mod tests {
|
||||
("file_picker.rs", include_str!("file_picker.rs")),
|
||||
("geometry.rs", include_str!("geometry.rs")),
|
||||
("library.rs", include_str!("library.rs")),
|
||||
("main.rs", include_str!("main.rs")),
|
||||
("app.rs", include_str!("app.rs")),
|
||||
("page_export.rs", include_str!("page_export.rs")),
|
||||
("pdf_doc.rs", include_str!("pdf_doc.rs")),
|
||||
("pdf_merge.rs", include_str!("pdf_merge.rs")),
|
||||
@@ -167,6 +167,9 @@ mod tests {
|
||||
("text.rs", include_str!("text.rs")),
|
||||
("ui.rs", include_str!("ui.rs")),
|
||||
("updater.rs", include_str!("updater.rs")),
|
||||
("import.rs", include_str!("import.rs")),
|
||||
("rnote.rs", include_str!("rnote.rs")),
|
||||
("xopp.rs", include_str!("xopp.rs")),
|
||||
];
|
||||
|
||||
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
||||
|
||||
+15
-7
@@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
||||
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
|
||||
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
|
||||
("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."),
|
||||
("Datei existiert nicht", "File does not exist"),
|
||||
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
||||
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
||||
@@ -150,7 +149,6 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Keine manifest.json vorhanden", "No manifest.json present"),
|
||||
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
|
||||
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
|
||||
("Keine Ordner oder PDF-Dateien", "No folders or PDF files"),
|
||||
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
||||
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
||||
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
|
||||
@@ -234,9 +232,7 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
||||
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
||||
("Pastell-Cyan", "Pastel cyan"),
|
||||
("PDF importieren", "Import PDF"),
|
||||
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
|
||||
("PDF nicht importiert: {err}", "PDF not imported: {err}"),
|
||||
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
|
||||
("PDF ohne Seite", "PDF without a page"),
|
||||
("PDF-Export läuft…", "PDF export running…"),
|
||||
@@ -368,7 +364,6 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
||||
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
||||
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
||||
("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"),
|
||||
("weich", "soft"),
|
||||
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
||||
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
||||
@@ -430,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
||||
("📂 Ordner öffnen", "📂 Open folder"),
|
||||
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||
("📄 PDF importieren …", "📄 Import PDF …"),
|
||||
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||
("📄 PDF importieren", "📄 Import PDF"),
|
||||
("📋 Einfügen", "📋 Paste"),
|
||||
("📑 Inhalt", "📑 Contents"),
|
||||
("📚 Alle", "📚 All"),
|
||||
@@ -454,4 +447,19 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
||||
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
||||
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
||||
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
|
||||
(", {count} Elemente übersprungen", ", {count} elements skipped"),
|
||||
("Nicht importiert: {err}", "Not imported: {err}"),
|
||||
("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 Ordner, PDF-, Xournal++- oder Rnote-Dateien", "No folders, PDF, Xournal++ or Rnote files"),
|
||||
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj) and Rnote (.rnote): strokes, shapes, texts and images are taken over, for Xournal++ also PDF backgrounds."),
|
||||
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++ and Rnote as a new notebook and write on them"),
|
||||
("📄 Importieren (PDF, Xournal++, Rnote) …", "📄 Import (PDF, Xournal++, Rnote) …"),
|
||||
("Keine Rnote-Datei", "Not an Rnote file"),
|
||||
("Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern", "Rnote file too old ({version}): please open it in a current Rnote and save it again"),
|
||||
];
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
|
||||
//! (`xopp`) and Rnote (`rnote`). This module holds what the importers share: building
|
||||
//! strokes, texts and images in the stored format and creating the notebook.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::document::{Notebook, Page, PageBackground, StrokeTool};
|
||||
use crate::storage::{self, NotebookLayout, NotebookManifest, SampleData, StrokeData};
|
||||
use crate::t;
|
||||
use crate::text::{TextBox, TextFont};
|
||||
|
||||
/// Largest accepted file after decompression (guards against gzip bombs).
|
||||
const MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters).
|
||||
pub const HIGHLIGHTER_ALPHA: f32 = 0.35;
|
||||
|
||||
/// What could not be taken over.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ImportReport {
|
||||
pub skipped: usize,
|
||||
/// The PDF background was referenced but not found or unreadable
|
||||
pub missing_pdf: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of an importer: pages, the next free stroke id, the layout (`None`: default).
|
||||
pub struct Imported {
|
||||
pub pages: Vec<Page>,
|
||||
pub next_stroke_id: u64,
|
||||
pub layout: Option<NotebookLayout>,
|
||||
pub report: ImportReport,
|
||||
}
|
||||
|
||||
/// Can the file be imported (by its name)?
|
||||
pub fn is_importable_path(path: &Path) -> bool {
|
||||
crate::file_picker::is_pdf_path(path)
|
||||
|| crate::xopp::is_xopp_path(path)
|
||||
|| crate::rnote::is_rnote_path(path)
|
||||
}
|
||||
|
||||
/// Imports a PDF, Xournal++ or Rnote file as a new notebook titled `title`.
|
||||
pub fn import_file(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
path: &Path,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||
if crate::xopp::is_xopp_path(path) {
|
||||
crate::xopp::create_notebook_from_xopp(base_dir, title, path)
|
||||
} else if crate::rnote::is_rnote_path(path) {
|
||||
crate::rnote::create_notebook_from_rnote(base_dir, title, path)
|
||||
} else {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
storage::create_notebook_from_pdf(base_dir, title, bytes)
|
||||
.map(|(nb, manifest, dir)| (nb, manifest, dir, ImportReport::default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompresses gzip data; other data is returned unchanged.
|
||||
pub fn gunzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
return Ok(bytes.to_vec());
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
flate2::read::MultiGzDecoder::new(bytes)
|
||||
.take(MAX_UNCOMPRESSED_BYTES)
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Creates a new notebook and fills it with the pages `build` creates in its folder (images
|
||||
/// and PDFs go to its asset folder). On failure nothing is left behind.
|
||||
pub fn create_notebook(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
build: impl FnOnce(&Path) -> Result<Imported, String>,
|
||||
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||
let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
let imported = build(&dir)?;
|
||||
let page_count = imported.pages.len() as u64;
|
||||
let notebook =
|
||||
Notebook::from_pages(imported.pages, 0, imported.next_stroke_id, page_count + 1);
|
||||
let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?;
|
||||
if imported.layout.is_some() {
|
||||
manifest.layout = imported.layout;
|
||||
}
|
||||
storage::save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||
Ok((notebook, manifest, imported.report))
|
||||
})();
|
||||
match result {
|
||||
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a page from stored-format strokes.
|
||||
pub fn page(
|
||||
id: u64,
|
||||
size: glam::Vec2,
|
||||
background: PageBackground,
|
||||
pdf_background: Option<crate::document::PdfBackground>,
|
||||
strokes: Vec<StrokeData>,
|
||||
) -> Page {
|
||||
storage::PageData {
|
||||
format_version: storage::CURRENT_FORMAT_VERSION,
|
||||
id,
|
||||
background,
|
||||
pdf_background,
|
||||
title: None,
|
||||
size: (size != crate::document::A4_SIZE).then(|| size.to_array()),
|
||||
strokes,
|
||||
}
|
||||
.to_page()
|
||||
}
|
||||
|
||||
/// Our stroke width and the sample pressures for absolute widths per point (page units):
|
||||
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
|
||||
/// ones less (down to a third of the widest).
|
||||
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
|
||||
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
|
||||
let width = widest / 1.5;
|
||||
let pressures = widths
|
||||
.iter()
|
||||
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
|
||||
.collect();
|
||||
(width, pressures)
|
||||
}
|
||||
|
||||
/// Pen or highlighter stroke through `points` (page units) with the given widths per point.
|
||||
pub fn ink_stroke(
|
||||
id: u64,
|
||||
mut color: [f32; 4],
|
||||
highlighter: bool,
|
||||
points: &[glam::Vec2],
|
||||
width: f32,
|
||||
pressures: &[f32],
|
||||
) -> StrokeData {
|
||||
let tool = if highlighter {
|
||||
if color[3] >= 0.99 {
|
||||
color[3] = HIGHLIGHTER_ALPHA;
|
||||
}
|
||||
StrokeTool::Highlighter
|
||||
} else {
|
||||
// Pens are always opaque (translucent pens are read as highlighters)
|
||||
color[3] = 1.0;
|
||||
StrokeTool::Pen
|
||||
};
|
||||
let samples = points
|
||||
.iter()
|
||||
.zip(pressures)
|
||||
.enumerate()
|
||||
.map(|(i, (p, &pressure))| SampleData {
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
p: pressure,
|
||||
t: i as f64 * 0.005,
|
||||
})
|
||||
.collect();
|
||||
StrokeData {
|
||||
id,
|
||||
color,
|
||||
samples,
|
||||
shape: None,
|
||||
tool,
|
||||
width,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text field with its upper left corner at `origin` and the axes (page vector per unit to
|
||||
/// the right and down). Without `wrap_width` it is as wide as its longest line.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn text_stroke(
|
||||
id: u64,
|
||||
content: &str,
|
||||
size: f32,
|
||||
font: TextFont,
|
||||
origin: glam::Vec2,
|
||||
axes: [glam::Vec2; 2],
|
||||
wrap_width: Option<f32>,
|
||||
mut color: [f32; 4],
|
||||
) -> StrokeData {
|
||||
let wrap_width = wrap_width.unwrap_or_else(|| {
|
||||
let unwrapped = TextBox::new(content.to_string(), size, font, f32::MAX / 4.0);
|
||||
let widest = unwrapped
|
||||
.layout()
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.width)
|
||||
.fold(0.0, f32::max);
|
||||
widest + size
|
||||
});
|
||||
let text = TextBox::new(content.to_string(), size, font, wrap_width);
|
||||
let corners = crate::text::frame_corners(origin, axes, text.box_size());
|
||||
color[3] = 1.0;
|
||||
StrokeData {
|
||||
id,
|
||||
color,
|
||||
samples: corner_samples(corners),
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 0.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
text: Some(text),
|
||||
}
|
||||
}
|
||||
|
||||
/// Image with the corners upper left, upper right, lower right, lower left.
|
||||
pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData {
|
||||
StrokeData {
|
||||
id,
|
||||
color: [1.0, 1.0, 1.0, 1.0],
|
||||
samples: corner_samples(corners),
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 0.0,
|
||||
finalized: true,
|
||||
image: Some(asset),
|
||||
text: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
|
||||
corners
|
||||
.iter()
|
||||
.map(|c| SampleData {
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
p: 1.0,
|
||||
t: 0.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fit_widths_keeps_the_drawn_widths() {
|
||||
let (width, pressures) = fit_widths(&[30.0, 15.0, 10.0, 2.0]);
|
||||
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
|
||||
assert!((drawn[0] - 30.0).abs() < 1e-3);
|
||||
assert!((drawn[1] - 15.0).abs() < 1e-3);
|
||||
assert!((drawn[2] - 10.0).abs() < 1e-3);
|
||||
// Thinner than a third of the widest point is not possible
|
||||
assert!((drawn[3] - 10.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
pub mod autosave;
|
||||
pub mod background;
|
||||
@@ -12,12 +13,14 @@ pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod i18n;
|
||||
pub mod image_layer;
|
||||
pub mod import;
|
||||
pub mod library;
|
||||
pub mod osk;
|
||||
pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
pub mod platform;
|
||||
pub mod rnote;
|
||||
pub mod ruler;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
@@ -27,3 +30,4 @@ pub mod text;
|
||||
pub mod ui;
|
||||
pub mod updater;
|
||||
pub mod viewport;
|
||||
pub mod xopp;
|
||||
+3
-4272
File diff suppressed because it is too large.
Load diff
+938
@@ -0,0 +1,938 @@
|
||||
//! Import of Rnote notebooks (.rnote) (Issue #47).
|
||||
//!
|
||||
//! An .rnote file is gzip-compressed JSON: `{"version": "0.x.y", "data": {"engine_snapshot":
|
||||
//! {...}}}`. The snapshot is Rnote's internal state without a specification, so it is read
|
||||
//! leniently as JSON and only what we need is picked out; files from Rnote 0.5.10 on are
|
||||
//! supported (the layout of older ones differs too much). Coordinates are pixels at the
|
||||
//! document's dpi.
|
||||
//!
|
||||
//! Pages: fixed-size and continuous documents become one page per Rnote page (a stroke goes
|
||||
//! to the page its middle lies on), infinite documents an endless notebook.
|
||||
|
||||
use base64::Engine;
|
||||
use glam::DVec2;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::document::{Notebook, PageBackground};
|
||||
use crate::import::{self, ImportReport, Imported};
|
||||
use crate::storage::{NotebookLayout, NotebookManifest, StrokeData};
|
||||
use crate::t;
|
||||
use crate::text::TextFont;
|
||||
|
||||
/// At most this many pages are created (protects against nonsensical document sizes).
|
||||
const MAX_PAGES: usize = 2000;
|
||||
|
||||
/// Is the file by its name an Rnote notebook?
|
||||
pub fn is_rnote_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("rnote"))
|
||||
}
|
||||
|
||||
/// Parsed Rnote document, still in Rnote pixels.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RnoteDocument {
|
||||
/// Page size in pixels and dpi
|
||||
pub format: DVec2,
|
||||
pub dpi: f64,
|
||||
/// Document bounds (upper left corner and size)
|
||||
pub origin: DVec2,
|
||||
pub size: DVec2,
|
||||
pub endless: bool,
|
||||
pub background: PageBackground,
|
||||
/// In drawing order
|
||||
pub items: Vec<RnoteItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RnoteItem {
|
||||
Ink {
|
||||
color: [f32; 4],
|
||||
highlighter: bool,
|
||||
points: Vec<DVec2>,
|
||||
/// Drawn width per point
|
||||
widths: Vec<f64>,
|
||||
},
|
||||
Text {
|
||||
content: String,
|
||||
size: f64,
|
||||
mono: bool,
|
||||
/// Upper left corner and axes
|
||||
affine: [DVec2; 3],
|
||||
max_width: Option<f64>,
|
||||
color: [f32; 4],
|
||||
},
|
||||
Bitmap {
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Straight (not premultiplied) RGBA
|
||||
pixels: Vec<u8>,
|
||||
corners: [DVec2; 4],
|
||||
},
|
||||
/// SVG image (Rnote stores imported PDF pages like this), rasterized on import
|
||||
Svg { svg: String, corners: [DVec2; 4] },
|
||||
/// Element without a counterpart or unreadable
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl RnoteItem {
|
||||
/// Point that decides which page the element belongs to.
|
||||
fn anchor(&self) -> Option<DVec2> {
|
||||
match self {
|
||||
RnoteItem::Ink { points, .. } => {
|
||||
let (min, max) = points.iter().fold(
|
||||
(DVec2::splat(f64::MAX), DVec2::splat(f64::MIN)),
|
||||
|(a, b), &p| (a.min(p), b.max(p)),
|
||||
);
|
||||
(!points.is_empty()).then(|| (min + max) / 2.0)
|
||||
}
|
||||
RnoteItem::Text { affine, .. } => Some(affine[2]),
|
||||
RnoteItem::Bitmap { corners, .. } | RnoteItem::Svg { corners, .. } => {
|
||||
Some((corners[0] + corners[2]) / 2.0)
|
||||
}
|
||||
RnoteItem::Unsupported => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn num(value: &Value, key: &str) -> Option<f64> {
|
||||
value.get(key)?.as_f64().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Vectors are stored as `[x, y]` (older versions possibly as `{"x": .., "y": ..}`).
|
||||
fn vec2(value: &Value) -> Option<DVec2> {
|
||||
match value {
|
||||
Value::Array(a) if a.len() == 2 => Some(DVec2::new(a[0].as_f64()?, a[1].as_f64()?)),
|
||||
Value::Object(_) => Some(DVec2::new(num(value, "x")?, num(value, "y")?)),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Affine transformation as x axis, y axis and translation. Since Rnote 0.15 `affine` holds
|
||||
/// the six values; before, `transform.affine` held a column-major 3×3 matrix.
|
||||
fn affine(value: &Value) -> Option<[DVec2; 3]> {
|
||||
let values = value
|
||||
.get("affine")
|
||||
.or_else(|| value.get("transform")?.get("affine"))?
|
||||
.as_array()?;
|
||||
let v: Vec<f64> = values.iter().filter_map(Value::as_f64).collect();
|
||||
let [a, b, c, d, e, f] = match v.len() {
|
||||
6 => [v[0], v[1], v[2], v[3], v[4], v[5]],
|
||||
9 => [v[0], v[1], v[3], v[4], v[6], v[7]],
|
||||
_ => return None,
|
||||
};
|
||||
Some([DVec2::new(a, b), DVec2::new(c, d), DVec2::new(e, f)])
|
||||
}
|
||||
|
||||
fn apply(affine: &[DVec2; 3], p: DVec2) -> DVec2 {
|
||||
affine[0] * p.x + affine[1] * p.y + affine[2]
|
||||
}
|
||||
|
||||
fn color(value: Option<&Value>) -> Option<[f32; 4]> {
|
||||
let value = value.filter(|v| v.is_object())?;
|
||||
let c = |k| num(value, k).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
|
||||
Some([
|
||||
c("r"),
|
||||
c("g"),
|
||||
c("b"),
|
||||
num(value, "a").map_or(1.0, |a| a.clamp(0.0, 1.0) as f32),
|
||||
])
|
||||
}
|
||||
|
||||
/// The one variant of an externally tagged enum: `{"name": {...}}`.
|
||||
fn variant(value: &Value) -> Option<(&str, &Value)> {
|
||||
let object = value.as_object()?;
|
||||
let (name, inner) = object.iter().next()?;
|
||||
Some((name.as_str(), inner))
|
||||
}
|
||||
|
||||
/// Width for the pressure according to Rnote's pressure curve.
|
||||
fn curve_width(curve: &str, width: f64, pressure: f64) -> f64 {
|
||||
let p = pressure.clamp(0.0, 1.0);
|
||||
match curve {
|
||||
"const" => width,
|
||||
"sqrt" => width * p.sqrt(),
|
||||
"cbrt" => width * p.cbrt(),
|
||||
"pow2" => width * p.powi(2),
|
||||
"pow3" => width * p.powi(3),
|
||||
_ => width * p,
|
||||
}
|
||||
}
|
||||
|
||||
/// Style options (smooth, rough or textured).
|
||||
struct Style {
|
||||
width: f64,
|
||||
stroke_color: Option<[f32; 4]>,
|
||||
fill_color: Option<[f32; 4]>,
|
||||
pressure_curve: String,
|
||||
}
|
||||
|
||||
fn style(value: Option<&Value>) -> Option<Style> {
|
||||
let (kind, options) = variant(value?)?;
|
||||
let curve = if kind == "rough" {
|
||||
"const".to_string()
|
||||
} else {
|
||||
options
|
||||
.get("pressure_curve")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("linear")
|
||||
.to_string()
|
||||
};
|
||||
Some(Style {
|
||||
width: num(options, "stroke_width").unwrap_or(1.0).max(0.1),
|
||||
stroke_color: color(options.get("stroke_color")),
|
||||
fill_color: color(options.get("fill_color")),
|
||||
pressure_curve: curve,
|
||||
})
|
||||
}
|
||||
|
||||
/// Points along a Bézier curve (without its start), at most every 2 pixels; for a line
|
||||
/// just its end.
|
||||
fn bezier(start: DVec2, controls: &[DVec2], end: DVec2) -> Vec<DVec2> {
|
||||
if controls.is_empty() {
|
||||
return vec![end];
|
||||
}
|
||||
let length = controls
|
||||
.iter()
|
||||
.chain([&end])
|
||||
.fold((0.0, start), |(sum, prev), &p| (sum + prev.distance(p), p))
|
||||
.0;
|
||||
let steps = ((length / 2.0).ceil() as usize).clamp(1, 32);
|
||||
(1..=steps)
|
||||
.map(|i| {
|
||||
let t = i as f64 / steps as f64;
|
||||
let u = 1.0 - t;
|
||||
match controls {
|
||||
[cp] => start * u * u + *cp * 2.0 * u * t + end * t * t,
|
||||
[cp1, cp2] => {
|
||||
start * u * u * u
|
||||
+ *cp1 * 3.0 * u * u * t
|
||||
+ *cp2 * 3.0 * u * t * t
|
||||
+ end * t * t * t
|
||||
}
|
||||
_ => start + (end - start) * t,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn element(value: &Value) -> Option<(DVec2, f64)> {
|
||||
Some((
|
||||
vec2(value.get("pos")?)?,
|
||||
num(value, "pressure").unwrap_or(0.5),
|
||||
))
|
||||
}
|
||||
|
||||
fn brushstroke(value: &Value, highlighter: bool) -> Option<RnoteItem> {
|
||||
let style = style(value.get("style"))?;
|
||||
let color = style.stroke_color?;
|
||||
let path = value.get("path")?;
|
||||
let (mut prev, mut prev_pressure) = element(path.get("start")?)?;
|
||||
let mut points = vec![prev];
|
||||
let mut pressures = vec![prev_pressure];
|
||||
for segment in path.get("segments")?.as_array()? {
|
||||
let Some((kind, seg)) = variant(segment) else {
|
||||
continue;
|
||||
};
|
||||
let Some((end, end_pressure)) = seg.get("end").and_then(element) else {
|
||||
continue;
|
||||
};
|
||||
let controls: Vec<DVec2> = match kind {
|
||||
"quadbezto" | "quadbez" => seg.get("cp").and_then(vec2).into_iter().collect(),
|
||||
"cubbezto" | "cubbez" => [seg.get("cp1"), seg.get("cp2")]
|
||||
.into_iter()
|
||||
.filter_map(|v| v.and_then(vec2))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let new = bezier(prev, &controls, end);
|
||||
let n = new.len() as f64;
|
||||
for (i, p) in new.into_iter().enumerate() {
|
||||
let t = (i + 1) as f64 / n;
|
||||
points.push(p);
|
||||
pressures.push(prev_pressure + (end_pressure - prev_pressure) * t);
|
||||
}
|
||||
prev = end;
|
||||
prev_pressure = end_pressure;
|
||||
}
|
||||
let widths = pressures
|
||||
.iter()
|
||||
.map(|&p| curve_width(&style.pressure_curve, style.width, p))
|
||||
.collect();
|
||||
Some(RnoteItem::Ink {
|
||||
highlighter: highlighter || color[3] < 0.99,
|
||||
color,
|
||||
points,
|
||||
widths,
|
||||
})
|
||||
}
|
||||
|
||||
/// Outline of a shape as polylines.
|
||||
fn shape_lines(shape: &Value) -> Option<Vec<Vec<DVec2>>> {
|
||||
let (kind, s) = variant(shape)?;
|
||||
let v = |k: &str| s.get(k).and_then(vec2);
|
||||
let lines = match kind {
|
||||
"line" => vec![vec![v("start")?, v("end")?]],
|
||||
"arrow" => {
|
||||
let (start, tip) = (v("start")?, v("tip")?);
|
||||
let back = (start - tip).normalize_or_zero();
|
||||
let length = (start.distance(tip) * 0.4).min(20.0);
|
||||
let wing = |angle: f64| tip + DVec2::from_angle(angle).rotate(back) * length;
|
||||
vec![vec![start, tip], vec![wing(0.5), tip, wing(-0.5)]]
|
||||
}
|
||||
"rect" => {
|
||||
let half = vec2(s.get("cuboid")?.get("half_extents")?)?;
|
||||
let affine = affine(s)?;
|
||||
let corners = [
|
||||
(-1.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(1.0, 1.0),
|
||||
(-1.0, 1.0),
|
||||
(-1.0, -1.0),
|
||||
];
|
||||
vec![corners
|
||||
.iter()
|
||||
.map(|&(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y)))
|
||||
.collect()]
|
||||
}
|
||||
"ellipse" => {
|
||||
let radii = v("radii")?;
|
||||
let affine = affine(s)?;
|
||||
vec![(0..=64)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||||
apply(&affine, DVec2::new(a.cos() * radii.x, a.sin() * radii.y))
|
||||
})
|
||||
.collect()]
|
||||
}
|
||||
"quadbez" | "cubbez" => {
|
||||
let start = v("start")?;
|
||||
let controls: Vec<DVec2> = ["cp", "cp1", "cp2"].iter().filter_map(|k| v(k)).collect();
|
||||
let mut points = vec![start];
|
||||
points.extend(bezier(start, &controls, v("end")?));
|
||||
vec![points]
|
||||
}
|
||||
"polyline" | "polygon" => {
|
||||
let start = v("start")?;
|
||||
let mut points = vec![start];
|
||||
points.extend(s.get("path")?.as_array()?.iter().filter_map(vec2));
|
||||
if kind == "polygon" {
|
||||
points.push(start);
|
||||
}
|
||||
vec![points]
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
fn shapestroke(value: &Value, highlighter: bool) -> Vec<RnoteItem> {
|
||||
let Some(Style {
|
||||
width,
|
||||
stroke_color,
|
||||
fill_color,
|
||||
..
|
||||
}) = style(value.get("style"))
|
||||
else {
|
||||
return vec![RnoteItem::Unsupported];
|
||||
};
|
||||
let (Some(color), Some(lines)) = (
|
||||
stroke_color.or(fill_color),
|
||||
value.get("shape").and_then(shape_lines),
|
||||
) else {
|
||||
return vec![RnoteItem::Unsupported];
|
||||
};
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|points| RnoteItem::Ink {
|
||||
color,
|
||||
highlighter: highlighter || color[3] < 0.99,
|
||||
widths: vec![width; points.len()],
|
||||
points,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn textstroke(value: &Value) -> Option<RnoteItem> {
|
||||
let style = value.get("text_style")?;
|
||||
Some(RnoteItem::Text {
|
||||
content: value.get("text")?.as_str()?.to_string(),
|
||||
size: num(style, "font_size").unwrap_or(32.0).max(1.0),
|
||||
mono: style
|
||||
.get("font_family")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
|
||||
affine: affine(value)?,
|
||||
max_width: num(style, "max_width").filter(|w| *w > 0.0),
|
||||
color: color(style.get("color")).unwrap_or([0.0, 0.0, 0.0, 1.0]),
|
||||
})
|
||||
}
|
||||
|
||||
fn bitmapimage(value: &Value) -> Option<RnoteItem> {
|
||||
let image = value.get("image")?;
|
||||
let width = num(image, "pixel_width")? as u32;
|
||||
let height = num(image, "pixel_height")? as u32;
|
||||
let mut pixels = base64::engine::general_purpose::STANDARD
|
||||
.decode(image.get("data")?.as_str()?)
|
||||
.ok()?;
|
||||
if pixels.len() != width as usize * height as usize * 4 {
|
||||
return None;
|
||||
}
|
||||
unpremultiply(&mut pixels);
|
||||
Some(RnoteItem::Bitmap {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
corners: rectangle_corners(value.get("rectangle")?)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn vectorimage(value: &Value) -> Option<RnoteItem> {
|
||||
Some(RnoteItem::Svg {
|
||||
svg: value.get("svg_data")?.as_str()?.to_string(),
|
||||
corners: rectangle_corners(value.get("rectangle")?)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Corners (upper left, upper right, lower right, lower left) of an Rnote rectangle: a
|
||||
/// box around the origin, placed by its affine transformation.
|
||||
fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
|
||||
let half = vec2(rectangle.get("cuboid")?.get("half_extents")?)?;
|
||||
let affine = affine(rectangle)?;
|
||||
Some(
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
.map(|(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
|
||||
fn unpremultiply(pixels: &mut [u8]) {
|
||||
for px in pixels.as_chunks_mut::<4>().0 {
|
||||
let a = px[3] as u32;
|
||||
if a > 0 && a < 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
||||
fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
|
||||
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
||||
let px_per_unit = 250.0 / 254.0;
|
||||
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
|
||||
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
|
||||
let transform = tiny_skia::Transform::from_scale(
|
||||
width as f32 / tree.size().width(),
|
||||
height as f32 / tree.size().height(),
|
||||
);
|
||||
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||
let mut pixels = pixmap.take();
|
||||
unpremultiply(&mut pixels);
|
||||
Some((width, height, pixels))
|
||||
}
|
||||
|
||||
/// Rnote's version as (major, minor, patch).
|
||||
fn version(text: &str) -> Option<(u64, u64, u64)> {
|
||||
let v = semver::Version::parse(text).ok()?;
|
||||
Some((v.major, v.minor, v.patch))
|
||||
}
|
||||
|
||||
/// Parses the (decompressed) JSON of an .rnote file.
|
||||
pub fn parse(json: &[u8]) -> Result<RnoteDocument, String> {
|
||||
let not_rnote = || t!("Keine Rnote-Datei").to_string();
|
||||
let wrapper: Value = serde_json::from_slice(json).map_err(|_| not_rnote())?;
|
||||
let version_text = wrapper
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(not_rnote)?;
|
||||
if version(version_text).is_none_or(|v| v < (0, 5, 10)) {
|
||||
return Err(t!(
|
||||
"Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern",
|
||||
version = version_text
|
||||
)
|
||||
.to_string());
|
||||
}
|
||||
let snapshot = wrapper
|
||||
.get("data")
|
||||
.and_then(|d| d.get("engine_snapshot"))
|
||||
.ok_or_else(not_rnote)?;
|
||||
let document = snapshot.get("document").ok_or_else(not_rnote)?;
|
||||
// Since Rnote 0.13 format, background and layout are in `config`
|
||||
let config = document.get("config").unwrap_or(document);
|
||||
let format = config.get("format").unwrap_or(&Value::Null);
|
||||
let dpi = num(format, "dpi").filter(|d| *d > 0.0).unwrap_or(96.0);
|
||||
let format_size = DVec2::new(
|
||||
num(format, "width").unwrap_or(dpi * 210.0 / 25.4).max(1.0),
|
||||
num(format, "height").unwrap_or(dpi * 297.0 / 25.4).max(1.0),
|
||||
);
|
||||
let background = match config
|
||||
.get("background")
|
||||
.and_then(|b| b.get("pattern"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("lines") => PageBackground::Lined,
|
||||
Some("grid" | "isometric_grid") => PageBackground::Grid5mm,
|
||||
Some("dots" | "isometric_dots") => PageBackground::DotGrid,
|
||||
_ => PageBackground::Blank,
|
||||
};
|
||||
let endless = matches!(
|
||||
config.get("layout").and_then(Value::as_str),
|
||||
Some("infinite" | "semi_infinite")
|
||||
);
|
||||
|
||||
let strokes = snapshot
|
||||
.get("stroke_components")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(not_rnote)?;
|
||||
let chrono = snapshot
|
||||
.get("chrono_components")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
// Slot maps are stored as slot arrays; the chrono components share the slot index
|
||||
let mut ordered: Vec<(u64, Vec<RnoteItem>)> = Vec::new();
|
||||
for (index, slot) in strokes.iter().enumerate() {
|
||||
let Some(stroke) = slot.get("value").filter(|v| !v.is_null()) else {
|
||||
continue;
|
||||
};
|
||||
let chrono = chrono.get(index).and_then(|c| c.get("value"));
|
||||
let t = chrono.and_then(|c| c.get("t")).and_then(Value::as_u64);
|
||||
let highlighter = chrono
|
||||
.and_then(|c| c.get("layer"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|l| l.eq_ignore_ascii_case("highlighter"));
|
||||
let items = match variant(stroke) {
|
||||
Some(("brushstroke", v)) => {
|
||||
vec![brushstroke(v, highlighter).unwrap_or(RnoteItem::Unsupported)]
|
||||
}
|
||||
Some(("shapestroke", v)) => shapestroke(v, highlighter),
|
||||
Some(("textstroke", v)) => vec![textstroke(v).unwrap_or(RnoteItem::Unsupported)],
|
||||
Some(("bitmapimage", v)) => vec![bitmapimage(v).unwrap_or(RnoteItem::Unsupported)],
|
||||
Some(("vectorimage", v)) => vec![vectorimage(v).unwrap_or(RnoteItem::Unsupported)],
|
||||
_ => vec![RnoteItem::Unsupported],
|
||||
};
|
||||
ordered.push((t.unwrap_or(index as u64), items));
|
||||
}
|
||||
ordered.sort_by_key(|(t, _)| *t);
|
||||
|
||||
Ok(RnoteDocument {
|
||||
format: format_size,
|
||||
dpi,
|
||||
origin: DVec2::new(
|
||||
num(document, "x").unwrap_or(0.0),
|
||||
num(document, "y").unwrap_or(0.0),
|
||||
),
|
||||
size: DVec2::new(
|
||||
num(document, "width").unwrap_or(format_size.x),
|
||||
num(document, "height").unwrap_or(format_size.y),
|
||||
),
|
||||
endless,
|
||||
background,
|
||||
items: ordered.into_iter().flat_map(|(_, items)| items).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a new notebook from an Rnote file.
|
||||
pub fn create_notebook_from_rnote(
|
||||
base_dir: &Path,
|
||||
title: &str,
|
||||
path: &Path,
|
||||
) -> Result<(Notebook, NotebookManifest, std::path::PathBuf, ImportReport), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let doc = parse(&import::gunzip(&bytes)?)?;
|
||||
import::create_notebook(base_dir, title, |dir| Ok(convert(&doc, dir)))
|
||||
}
|
||||
|
||||
/// A target page: where its Rnote page starts, and how Rnote pixels map onto it.
|
||||
struct Target {
|
||||
rnote_origin: DVec2,
|
||||
origin: glam::Vec2,
|
||||
scale: f32,
|
||||
size: glam::Vec2,
|
||||
strokes: Vec<StrokeData>,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
fn map(&self, p: DVec2) -> glam::Vec2 {
|
||||
self.origin + (p - self.rnote_origin).as_vec2() * self.scale
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
|
||||
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||
let mut report = ImportReport::default();
|
||||
// Page units per Rnote pixel at the document's dpi
|
||||
let units_per_px = (crate::document::UNITS_PER_MM as f64 * 25.4 / doc.dpi) as f32;
|
||||
|
||||
let (columns, mut targets) = if doc.endless {
|
||||
let target = Target {
|
||||
rnote_origin: DVec2::ZERO,
|
||||
origin: crate::document::ENDLESS_HOME,
|
||||
scale: units_per_px,
|
||||
size: crate::document::ENDLESS_PAGE_SIZE,
|
||||
strokes: Vec::new(),
|
||||
};
|
||||
(1, vec![target])
|
||||
} else {
|
||||
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||
// aspect ratio, centred horizontally and aligned at the top
|
||||
let size_pt = (doc.format / doc.dpi * 72.0).as_vec2();
|
||||
let size = crate::document::pdf_page_size(size_pt.x, size_pt.y);
|
||||
let [origin, top_right, _, bottom_left] =
|
||||
crate::document::pdf_placement(size_pt.x, size_pt.y, size);
|
||||
let scale_pt =
|
||||
((top_right.x - origin.x) / size_pt.x).min((bottom_left.y - origin.y) / size_pt.y);
|
||||
let scale = scale_pt * (72.0 / doc.dpi) as f32;
|
||||
// Rnote grows documents page by page; tolerate rounding in the stored size
|
||||
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
|
||||
let columns = count(doc.size.x, doc.format.x);
|
||||
let rows = count(doc.size.y, doc.format.y);
|
||||
let rows = rows.min((MAX_PAGES / columns).max(1));
|
||||
let targets = (0..rows * columns)
|
||||
.map(|i| Target {
|
||||
rnote_origin: doc.origin
|
||||
+ DVec2::new((i % columns) as f64, (i / columns) as f64) * doc.format,
|
||||
origin,
|
||||
scale,
|
||||
size,
|
||||
strokes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
(columns, targets)
|
||||
};
|
||||
let rows = targets.len() / columns;
|
||||
|
||||
let mut next_id: u64 = 1;
|
||||
for item in &doc.items {
|
||||
let Some(anchor) = item.anchor() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let cell = ((anchor - doc.origin) / doc.format).floor();
|
||||
let index = if doc.endless {
|
||||
0
|
||||
} else {
|
||||
let column = (cell.x.max(0.0) as usize).min(columns - 1);
|
||||
let row = (cell.y.max(0.0) as usize).min(rows - 1);
|
||||
row * columns + column
|
||||
};
|
||||
let target = &mut targets[index];
|
||||
let id = next_id;
|
||||
next_id += 1;
|
||||
let stroke = match item {
|
||||
RnoteItem::Ink {
|
||||
color,
|
||||
highlighter,
|
||||
points,
|
||||
widths,
|
||||
} => {
|
||||
let points: Vec<glam::Vec2> = points.iter().map(|&p| target.map(p)).collect();
|
||||
let widths: Vec<f32> = widths.iter().map(|&w| w as f32 * target.scale).collect();
|
||||
let (width, pressures) = import::fit_widths(&widths);
|
||||
import::ink_stroke(id, *color, *highlighter, &points, width, &pressures)
|
||||
}
|
||||
RnoteItem::Text {
|
||||
content,
|
||||
size,
|
||||
mono,
|
||||
affine,
|
||||
max_width,
|
||||
color,
|
||||
} => {
|
||||
let font = if *mono {
|
||||
TextFont::Mono
|
||||
} else {
|
||||
TextFont::Sans
|
||||
};
|
||||
import::text_stroke(
|
||||
id,
|
||||
content,
|
||||
*size as f32 * target.scale,
|
||||
font,
|
||||
target.map(affine[2]),
|
||||
[affine[0].as_vec2(), affine[1].as_vec2()],
|
||||
max_width.map(|w| w as f32 * target.scale),
|
||||
*color,
|
||||
)
|
||||
}
|
||||
RnoteItem::Bitmap {
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
corners,
|
||||
} => match assets.import_rgba(*width, *height, pixels.clone()) {
|
||||
Ok(image) => import::image_stroke(id, image.asset, corners.map(|c| target.map(c))),
|
||||
Err(_) => {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
RnoteItem::Svg { svg, corners } => {
|
||||
let corners = corners.map(|c| target.map(c));
|
||||
let size = glam::Vec2::new(
|
||||
corners[0].distance(corners[1]),
|
||||
corners[0].distance(corners[3]),
|
||||
);
|
||||
let image = rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
|
||||
assets.import_rgba(width, height, pixels).ok()
|
||||
});
|
||||
match image {
|
||||
Some(image) => import::image_stroke(id, image.asset, corners),
|
||||
None => {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
RnoteItem::Unsupported => unreachable!("has no anchor"),
|
||||
};
|
||||
target.strokes.push(stroke);
|
||||
}
|
||||
|
||||
let pages = targets
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, target)| {
|
||||
import::page(
|
||||
i as u64 + 1,
|
||||
target.size,
|
||||
doc.background,
|
||||
None,
|
||||
target.strokes,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Imported {
|
||||
pages,
|
||||
next_stroke_id: next_id,
|
||||
layout: doc.endless.then_some(NotebookLayout::Endless),
|
||||
report,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::StrokeTool;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn slot(value: Value) -> Value {
|
||||
json!({ "value": value, "version": 1 })
|
||||
}
|
||||
|
||||
/// A small Rnote 0.15 document: two A4 pages at 96 dpi.
|
||||
fn sample(layout: &str) -> Value {
|
||||
let brush = json!({ "brushstroke": {
|
||||
"path": {
|
||||
"start": { "pos": [100.0, 100.0], "pressure": 0.5 },
|
||||
"segments": [
|
||||
{ "lineto": { "end": { "pos": [120.0, 100.0], "pressure": 1.0 } } },
|
||||
{ "cubbezto": { "cp1": [130.0, 110.0], "cp2": [140.0, 110.0],
|
||||
"end": { "pos": [150.0, 100.0], "pressure": 0.25 } } }
|
||||
]
|
||||
},
|
||||
"style": { "smooth": { "stroke_width": 4.0,
|
||||
"stroke_color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 },
|
||||
"fill_color": null, "pressure_curve": "linear" } }
|
||||
}});
|
||||
let rect = json!({ "shapestroke": {
|
||||
"shape": { "rect": { "cuboid": { "half_extents": [50.0, 20.0] },
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 300.0, 1300.0] } },
|
||||
"style": { "rough": { "stroke_width": 2.0,
|
||||
"stroke_color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } } }
|
||||
}});
|
||||
let text = json!({ "textstroke": {
|
||||
"text": "Hallo",
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 50.0, 60.0],
|
||||
"text_style": { "font_family": "serif", "font_size": 32.0, "max_width": null,
|
||||
"color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } }
|
||||
}});
|
||||
let pixels = base64::engine::general_purpose::STANDARD.encode([128u8, 0, 0, 128].repeat(4));
|
||||
let bitmap = json!({ "bitmapimage": {
|
||||
"image": { "data": pixels, "pixel_width": 2, "pixel_height": 2,
|
||||
"memory_format": "R8g8b8a8Premultiplied" },
|
||||
"rectangle": { "cuboid": { "half_extents": [10.0, 10.0] },
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 400.0] }
|
||||
}});
|
||||
let svg = json!({ "vectorimage": {
|
||||
"svg_data": "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10' fill='green'/></svg>",
|
||||
"intrinsic_size": [10.0, 10.0],
|
||||
"rectangle": { "cuboid": { "half_extents": [40.0, 40.0] },
|
||||
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 1500.0] }
|
||||
}});
|
||||
let marker = json!({ "brushstroke": {
|
||||
"path": { "start": { "pos": [10.0, 10.0], "pressure": 1.0 },
|
||||
"segments": [ { "lineto": { "end": { "pos": [90.0, 10.0], "pressure": 1.0 } } } ] },
|
||||
"style": { "smooth": { "stroke_width": 12.0,
|
||||
"stroke_color": { "r": 1.0, "g": 1.0, "b": 0.0, "a": 1.0 },
|
||||
"fill_color": null, "pressure_curve": "const" } }
|
||||
}});
|
||||
let chrono = |t: u32, layer: &str| slot(json!({ "t": t, "layer": layer }));
|
||||
json!({
|
||||
"version": "0.15.0",
|
||||
"data": { "engine_snapshot": {
|
||||
"document": {
|
||||
"config": {
|
||||
"format": { "width": 793.701, "height": 1122.52, "dpi": 96.0 },
|
||||
"background": { "pattern": "grid" },
|
||||
"layout": layout
|
||||
},
|
||||
"x": 0.0, "y": 0.0, "width": 793.701, "height": 2245.04
|
||||
},
|
||||
"camera": {},
|
||||
// Slot 0 is the slot map's unused sentinel; the text was drawn first
|
||||
"stroke_components": [
|
||||
{ "value": null, "version": 0 },
|
||||
slot(brush), slot(rect), slot(text), slot(bitmap), slot(svg), slot(marker)
|
||||
],
|
||||
"chrono_components": [
|
||||
{ "value": null, "version": 0 },
|
||||
chrono(2, "user_layer"), chrono(3, "user_layer"), chrono(1, "user_layer"),
|
||||
chrono(4, "image"), chrono(5, "image"), chrono(6, "highlighter")
|
||||
],
|
||||
"chrono_counter": 7
|
||||
}}
|
||||
})
|
||||
}
|
||||
|
||||
fn write_rnote(dir: &Path, value: &Value) -> std::path::PathBuf {
|
||||
let path = dir.join("Skizze.rnote");
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(value.to_string().as_bytes()).unwrap();
|
||||
std::fs::write(&path, encoder.finish().unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rnote() {
|
||||
let doc = parse(sample("fixed_size").to_string().as_bytes()).unwrap();
|
||||
assert_eq!(doc.background, PageBackground::Grid5mm);
|
||||
assert!(!doc.endless);
|
||||
// In drawing order: the text was drawn first
|
||||
assert!(matches!(doc.items[0], RnoteItem::Text { .. }));
|
||||
match &doc.items[1] {
|
||||
RnoteItem::Ink {
|
||||
points,
|
||||
widths,
|
||||
color,
|
||||
highlighter,
|
||||
} => {
|
||||
assert!(!highlighter);
|
||||
assert_eq!(*color, [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(points.first(), Some(&DVec2::new(100.0, 100.0)));
|
||||
assert_eq!(points.last(), Some(&DVec2::new(150.0, 100.0)));
|
||||
assert!(points.len() > 3, "the curve is sampled");
|
||||
assert_eq!(widths[0], 2.0);
|
||||
assert_eq!(widths[1], 4.0);
|
||||
assert_eq!(*widths.last().unwrap(), 1.0);
|
||||
}
|
||||
other => panic!("expected ink, got {other:?}"),
|
||||
}
|
||||
match &doc.items[2] {
|
||||
RnoteItem::Ink { points, .. } => {
|
||||
assert_eq!(points.len(), 5);
|
||||
assert_eq!(points[0], DVec2::new(250.0, 1280.0));
|
||||
assert_eq!(points[2], DVec2::new(350.0, 1320.0));
|
||||
}
|
||||
other => panic!("expected the rectangle, got {other:?}"),
|
||||
}
|
||||
match &doc.items[3] {
|
||||
RnoteItem::Bitmap { pixels, .. } => assert_eq!(&pixels[..4], &[255, 0, 0, 128]),
|
||||
other => panic!("expected a bitmap, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(doc.items[4], RnoteItem::Svg { .. }));
|
||||
assert!(matches!(
|
||||
doc.items[5],
|
||||
RnoteItem::Ink {
|
||||
highlighter: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_older_formats() {
|
||||
// Before 0.13: format, background and layout directly in the document; before 0.15:
|
||||
// transformations as column-major 3×3 matrix
|
||||
let mut value = sample("continuous_vertical");
|
||||
value["version"] = json!("0.9.4");
|
||||
let document = &mut value["data"]["engine_snapshot"]["document"];
|
||||
let config = document["config"].take();
|
||||
for key in ["format", "background", "layout"] {
|
||||
document[key] = config[key].clone();
|
||||
}
|
||||
document.as_object_mut().unwrap().remove("config");
|
||||
let text =
|
||||
&mut value["data"]["engine_snapshot"]["stroke_components"][3]["value"]["textstroke"];
|
||||
text.as_object_mut().unwrap().remove("affine");
|
||||
text["transform"] = json!({ "affine": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 1.0] });
|
||||
let doc = parse(value.to_string().as_bytes()).unwrap();
|
||||
assert_eq!(doc.background, PageBackground::Grid5mm);
|
||||
match &doc.items[0] {
|
||||
RnoteItem::Text { affine, .. } => assert_eq!(affine[2], DVec2::new(50.0, 60.0)),
|
||||
other => panic!("expected a text, got {other:?}"),
|
||||
}
|
||||
|
||||
value["version"] = json!("0.5.5");
|
||||
assert!(parse(value.to_string().as_bytes()).is_err());
|
||||
assert!(parse(b"{\"a\": 1}").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_notebook_from_rnote() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let path = write_rnote(source_dir.path(), &sample("fixed_size"));
|
||||
let (notebook, manifest, dir, report) =
|
||||
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
|
||||
assert_eq!(report, ImportReport::default());
|
||||
assert_eq!(manifest.layout, NotebookManifest::new("x", "x").layout);
|
||||
|
||||
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
|
||||
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
let [first, second] = &reloaded.pages[..] else {
|
||||
panic!("two pages expected");
|
||||
};
|
||||
assert_eq!(first.background, PageBackground::Grid5mm);
|
||||
// Text, pen, bitmap, highlighter on page 1; rectangle and SVG on page 2
|
||||
assert_eq!(first.strokes.len(), 4);
|
||||
assert_eq!(second.strokes.len(), 2);
|
||||
assert!(first.strokes[0].is_text());
|
||||
let pen = &first.strokes[1];
|
||||
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||
// 96 dpi: 100 px = 26.46 mm
|
||||
assert!((pen.points[0].position.x - 264.6).abs() < 0.5);
|
||||
assert!(first.strokes[2].is_image());
|
||||
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
|
||||
// The rectangle sits on page 2 in page coordinates: 250 px - 0 / 1280 px - 1122.52 px
|
||||
let rect = &second.strokes[0];
|
||||
assert!((rect.points[0].position.x - 661.5).abs() < 0.5);
|
||||
assert!((rect.points[0].position.y - 416.7).abs() < 0.5);
|
||||
assert!(second.strokes[1].is_image());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infinite_documents_become_endless() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let path = write_rnote(source_dir.path(), &sample("infinite"));
|
||||
let (notebook, manifest, _, _) =
|
||||
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
|
||||
assert_eq!(manifest.layout, Some(NotebookLayout::Endless));
|
||||
assert_eq!(notebook.pages.len(), 1);
|
||||
assert!(notebook.pages[0].is_endless());
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 6);
|
||||
let pen = ¬ebook.pages[0].strokes[1];
|
||||
let home = crate::document::ENDLESS_HOME;
|
||||
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2385,7 +2385,7 @@ mod tests {
|
||||
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
|
||||
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||
|
||||
// Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern
|
||||
// Wie Taste N in app.rs: neue Seite anhängen, Strich darauf, speichern
|
||||
notebook.next_page();
|
||||
let idx = notebook.current_page_index;
|
||||
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
|
||||
|
||||
@@ -338,7 +338,7 @@ pub enum UiAction {
|
||||
/// Bilddatei auf der aktuellen Seite einfügen
|
||||
InsertImageFile(std::path::PathBuf),
|
||||
/// PDF als neues Notizbuch importieren
|
||||
ImportPdf(std::path::PathBuf),
|
||||
ImportFile(std::path::PathBuf),
|
||||
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||
ExportPage(PageExportKind),
|
||||
/// Titel einer Seite setzen (leer = entfernen)
|
||||
@@ -669,9 +669,9 @@ impl UiState {
|
||||
self.open_file_picker(PickerKind::Image);
|
||||
}
|
||||
|
||||
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
|
||||
pub fn open_pdf_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Pdf);
|
||||
/// Opens the file picker "Import" (PDF, Xournal++, Rnote) in the last used folder.
|
||||
pub fn open_import_picker(&mut self) {
|
||||
self.open_file_picker(PickerKind::Import);
|
||||
}
|
||||
|
||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||
@@ -2827,14 +2827,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!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
||||
),
|
||||
PickerKind::Pdf => (
|
||||
t!("PDF importieren"),
|
||||
t!("📄 PDF importieren"),
|
||||
PickerKind::Import => (
|
||||
t!("Importieren"),
|
||||
t!("📄 Importieren"),
|
||||
"📄",
|
||||
t!("Keine Ordner oder PDF-Dateien"),
|
||||
t!("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien"),
|
||||
t!(
|
||||
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
|
||||
Beim Export liegen deine Notizen über den Originalseiten."
|
||||
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
|
||||
beim Export liegen deine Notizen über den Originalseiten. \
|
||||
Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder \
|
||||
werden übernommen, bei Xournal++ 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());
|
||||
actions.push(match picker_kind {
|
||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||
PickerKind::Pdf => UiAction::ImportPdf(path),
|
||||
PickerKind::Import => UiAction::ImportFile(path),
|
||||
});
|
||||
close = true;
|
||||
}
|
||||
@@ -4195,11 +4197,11 @@ pub fn render_ui(
|
||||
state.open_image_picker();
|
||||
}
|
||||
if ui
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …")))
|
||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben"))
|
||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote) …")))
|
||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben"))
|
||||
.clicked()
|
||||
{
|
||||
state.open_pdf_picker();
|
||||
state.open_import_picker();
|
||||
}
|
||||
|
||||
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
||||
@@ -6133,7 +6135,7 @@ mod tests {
|
||||
const UI_SOURCES: [(&str, &str); 4] = [
|
||||
("ui.rs", include_str!("ui.rs")),
|
||||
("selection.rs", include_str!("selection.rs")),
|
||||
("main.rs", include_str!("main.rs")),
|
||||
("app.rs", include_str!("app.rs")),
|
||||
("file_picker.rs", include_str!("file_picker.rs")),
|
||||
];
|
||||
|
||||
|
||||
@@ -389,6 +389,14 @@ impl Fling {
|
||||
velocity
|
||||
}
|
||||
|
||||
/// The glide continues from `now` (after [`Self::release`] with the time the finger was
|
||||
/// lifted, which may lie a little in the past).
|
||||
pub fn restart_clock(&mut self, now: std::time::Instant) {
|
||||
if self.active {
|
||||
self.last = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
||||
pub fn stop(&mut self) {
|
||||
*self = Self::default();
|
||||
@@ -430,6 +438,25 @@ mod tests {
|
||||
PageStack::new(vec![A4; count], current)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fling_measures_with_event_times_handled_late() {
|
||||
use std::time::{Duration, Instant};
|
||||
let t0 = Instant::now();
|
||||
let ms = |n: u64| t0 + Duration::from_millis(n);
|
||||
let mut fling = Fling::default();
|
||||
// The finger moved until 100 ms and was lifted at 105 ms; the app only got to the
|
||||
// lift 300 ms later, so the glide starts from there
|
||||
for i in 0..=10 {
|
||||
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||
}
|
||||
let velocity = fling.release(ms(105));
|
||||
assert!(velocity.y < -2000.0, "{velocity:?}");
|
||||
assert!(fling.is_active());
|
||||
fling.restart_clock(ms(405));
|
||||
let first = fling.step(ms(421)).unwrap();
|
||||
assert!(first.y < -20.0 && first.y > -60.0, "{first:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fling_glides_and_slows_down() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -14,6 +14,10 @@ docs of the original crate were removed.
|
||||
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
|
||||
the title bar while the pen is near the screen, so a resting palm does not move the window
|
||||
(Issue #39).
|
||||
- `src/platform/wayland.rs` and the touch handler: `take_touch_time` returns the compositor
|
||||
timestamp of each touch event (Started, Moved, Ended), so ink-notes measures the swipe speed
|
||||
for gliding with the real event times and not with the time it got around to handling them
|
||||
(Issue #28).
|
||||
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
||||
capped lints like a crates.io dependency.
|
||||
|
||||
|
||||
Vendored
+30
@@ -28,6 +28,36 @@ pub(crate) fn decoration_touches_ignored() -> bool {
|
||||
.is_some_and(|until| std::time::Instant::now() < until)
|
||||
}
|
||||
|
||||
/// ink-notes patch: compositor timestamps (ms) of the touch events not yet taken, in event order.
|
||||
static TOUCH_TIMES: std::sync::Mutex<
|
||||
std::collections::VecDeque<(u64, crate::event::TouchPhase, u32)>,
|
||||
> = std::sync::Mutex::new(std::collections::VecDeque::new());
|
||||
|
||||
/// ink-notes patch: remembers the compositor time of a touch event that is about to be sent.
|
||||
pub(crate) fn push_touch_time(id: u64, phase: crate::event::TouchPhase, time: u32) {
|
||||
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Nobody takes them (e.g. an app that never asks): keep only the latest
|
||||
if times.len() >= 256 {
|
||||
times.pop_front();
|
||||
}
|
||||
times.push_back((id, phase, time));
|
||||
}
|
||||
|
||||
/// ink-notes patch: compositor time in milliseconds (arbitrary origin, wraps around) of the
|
||||
/// touch event with this id and phase (`Started`, `Moved` or `Ended`; `Cancelled` has none),
|
||||
/// for the event currently being handled. Call it once for every such `WindowEvent::Touch`.
|
||||
/// `None` on X11 or if the time is unknown.
|
||||
pub fn take_touch_time(id: u64, phase: crate::event::TouchPhase) -> Option<u32> {
|
||||
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Older entries that were never taken are skipped
|
||||
while let Some((i, p, time)) = times.pop_front() {
|
||||
if i == id && p == phase {
|
||||
return Some(time);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
|
||||
pub trait EventLoopWindowTargetExtWayland {
|
||||
/// True if the [`EventLoopWindowTarget`] uses Wayland.
|
||||
|
||||
@@ -83,6 +83,9 @@ impl TouchHandler for WinitState {
|
||||
.touch_map
|
||||
.insert(id, TouchPoint { surface, location, frame_of: None });
|
||||
|
||||
// ink-notes patch: keep the event time for `take_touch_time`
|
||||
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Started, time);
|
||||
|
||||
self.events_sink.push_window_event(
|
||||
WindowEvent::Touch(Touch {
|
||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||
@@ -138,6 +141,8 @@ impl TouchHandler for WinitState {
|
||||
None => return,
|
||||
};
|
||||
|
||||
// ink-notes patch: keep the event time for `take_touch_time`
|
||||
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Ended, time);
|
||||
self.events_sink.push_window_event(
|
||||
WindowEvent::Touch(Touch {
|
||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||
@@ -157,7 +162,7 @@ impl TouchHandler for WinitState {
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
touch: &WlTouch,
|
||||
_: u32,
|
||||
time: u32,
|
||||
id: i32,
|
||||
position: (f64, f64),
|
||||
) {
|
||||
@@ -192,6 +197,9 @@ impl TouchHandler for WinitState {
|
||||
|
||||
touch_point.location = LogicalPosition::<f64>::from(position);
|
||||
|
||||
// ink-notes patch: keep the event time for `take_touch_time`
|
||||
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Moved, time);
|
||||
|
||||
self.events_sink.push_window_event(
|
||||
WindowEvent::Touch(Touch {
|
||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||
|
||||
Reference in new issue
Block a user