Compare commits

..
14 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 ca6f64ec8e chore(release): bump version to 0.36.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:56:16 +01:00
haraldandClaude Opus 5.5 46026eb8e5 feat: import Rnote notebooks (Issue #47)
The import (menu entry and drag & drop) now also accepts .rnote files
from Rnote 0.5.10 on, including the transformation and document config
layouts that changed in 0.13 and 0.15. Taken over: brush strokes with
their pressure curve, shapes as outlines, texts, bitmap images and SVG
images, which are rasterized with resvg (Rnote stores imported PDF
pages as SVG). Fixed-size and continuous documents keep their pages;
infinite ones become an endless notebook.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:40:51 +01:00
haraldandClaude Opus 5.5 2bd697ef82 feat: import Xournal++ and Xournal notebooks (Issue #47)
The import menu entry (formerly "Import PDF") and drag & drop now also
accept .xopp and .xoj files. Each Xournal page becomes a page of a new
notebook: strokes with pen pressure, highlighters, texts, embedded
images, lined/squared/dotted paper, image backgrounds and PDF
backgrounds (looked up at the stored path and next to the file). All
layers are flattened in order; LaTeX formulas are skipped and counted
in the status message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:10:34 +01:00
haraldandClaude Opus 5.5 b5d9d72e79 chore(release): bump version to 0.35.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:52:15 +01:00
haraldandClaude Opus 5.5 955eaa4095 perf: simplify stroke outlines before PDF export
Each stroke mesh has about 16 points per pen sample, and every one of them
went into the PDF as a polygon point: a full handwritten page (1650 strokes)
took 2.9 s to export, and PNG export and copying a selection as an image
took as long, because both go through the PDF. The outline is now simplified
with a closed Douglas-Peucker that measures against segments, at most 0.01 mm
from the drawn outline. The same page now takes 0.44 s (PNG 0.55 s), the PDF
shrinks from 10.7 MB to 1.4 MB, and the rendered PNG differs only in edge
anti-aliasing (max 21/255). INK_NOTES_EXACT_OUTLINES=1 exports every point
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:45:17 +01:00
haraldandClaude Opus 5.5 330c29193f chore(release): bump version to 0.34.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:10:23 +01:00
haraldandClaude Opus 5.5 dd515787ec fix: measure the swipe speed with the compositor's touch event times (Issue #28)
Pia's log shows the lift arriving 200-300 ms after the last move on every
swipe, so the release looked like a resting finger and nothing glided. The
vendored winit now keeps the Wayland timestamp of each touch event
(take_touch_time), and the fling measures with those times instead of when
the app got around to handling the events. The diagnostics log both gaps and
any event handling that blocks the app for more than 40 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:08:13 +01:00
haraldandClaude Opus 5.5 b11fb2d762 refactor: move the event loop into the library as app::run (prep for Issue #42)
src/main.rs is now only the desktop entry point; the app itself lives in
src/app.rs, so other entry points such as android_main can start it. The code
is unchanged apart from crate:: paths; the source scans in the i18n and UI
tests and the docs point to app.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:02:43 +01:00
haraldandClaude Opus 5.5 e8a7675f8d chore(release): bump version to 0.34.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:17:21 +01:00
haraldandClaude Opus 5.5 0992b9bc0e fix: keep the measured swipe when the view touches the notebook edge (Issue #28)
Clamping the continuous or endless view reset the fling while the finger was
still down, so the release measured 0 px/s. Only an active glide stops there
now. The fling diagnostics also log moves, path length and the time since
the last move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:15:32 +01:00
haraldandClaude Opus 5.5 78554c77ee chore(release): bump version to 0.34.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:50:58 +02:00
haraldandClaude Opus 5.5 1e34c01ad2 fix: eraser size menu, pointed shape corners, flat tiles for slides (Issues #38, #40, #41, #28)
- #38: the eraser size button opens a menu like the pens: four presets and a slider
  (2–30 mm diameter)
- #41: shape corners stay pointed down to about 14° (miter limit 8 instead of 2)
- #40: page picker tiles are as high as the tallest page needs, so slides get flat tiles;
  the frame follows the page instead of the whole tile
- #28: the fling diagnosis also logs finger starts, mouse buttons and wheel/touchpad events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:46:49 +02:00
haraldandClaude Opus 5.5 69e1640dc8 chore(release): bump version to 0.33.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:08:28 +02:00
haraldandClaude Opus 5.5 2a06e2604a fix: palm rejection, eraser sizes, PDF thumbnails, even polygon strokes (Issues #37-#41)
- #41: snapped triangles, rectangles and polygons keep the full stroke width on every
  side; corners are mitered, sharp ones bevelled (the old corner offset thinned the edges)
- #38: four eraser sizes, cycled by a button below the eraser modes and remembered in
  ui.json
- #37: a selection made with the pen's select button can be moved with the bare pen
- #39: fingers resting on the screen when the pen comes near, or touching it while the pen
  is near (and 0.7 s after), are ignored until lifted; the vendored winit ignores touches
  on the title bar meanwhile
- #40: the page picker shows imported PDF pages, rendered small in the background

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:06:39 +02:00
29 changed files with 7418 additions and 4245 deletions

No files matched your search

Generated
+134 -3
View File
@@ -539,6 +539,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -790,6 +796,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1381,6 +1393,12 @@ dependencies = [
"quick-error",
]
[[package]]
name = "imagesize"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -1393,14 +1411,16 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.32.0"
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
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.32.0"
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"
+13 -7
View File
@@ -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).
@@ -27,7 +27,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
@@ -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.
@@ -55,7 +57,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Durchgehendes Scrollen, endlose Fläche oder feste Seiten, je Notizbuch:** Beim Anlegen wählbar (Vorgabe: durchgehend; oder endlos), im ☰-Menü umschaltbar – dort auch auf feste Seiten (Seite für Seite blättern mit Pfeilen in der Werkzeugleiste). Durchgehend hängen die Seiten lückenlos untereinander, eine dunkle Linie markiert die Grenze; schnelles Wischen gleitet nach, und mit der Bildlaufleiste am Rand gegenüber der Werkzeugleiste springt man samt Seitenzahl durch lange PDFs; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Endlos ist eine einzige Fläche ohne Rand, die oben links beginnt und nach rechts und unten beliebig weitergeht; der PDF-Export enthält nur den beschriebenen Bereich.
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder (samt importierter PDF-Seite) oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
### Notizbücher und Speichern
@@ -72,7 +74,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
### Eingabegeräte
- Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste).
- Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert.
- Solange der Stift aufliegt oder in der Nähe ist, und kurz danach, werden Berührungen mit der Hand ignoriert – auch auf der Titelleiste. Liegt die Hand schon auf, wenn der Stift kommt, wird sie bis zum Abheben ignoriert.
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten.
### Updates
@@ -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
+13 -7
View File
@@ -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).
@@ -27,7 +27,7 @@ The interface is available in English and German; ink-notes follows the system l
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only. The back end of the pen erases too.
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
@@ -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.
@@ -55,7 +57,7 @@ The interface is available in English and German; ink-notes follows the system l
- **Continuous scrolling, endless canvas or fixed pages, per notebook:** chosen when creating a notebook (default: continuous; or endless) and switchable in the ☰ menu – where fixed pages are available too (page by page with arrows in the toolbar). In continuous mode the pages hang seamlessly below each other with a dark line marking the boundary. A quick swipe keeps gliding, and the scrollbar on the edge opposite the toolbar jumps through long PDFs with the page number shown. Writing in the bottom fifth of the last page adds a new page automatically. Endless is a single borderless canvas that grows to the right and down as far as you like, starting at the top left; the PDF export only contains the area you used.
- **Insert and delete pages:** ➕ in the toolbar inserts an empty page after the current one. Delete it in the ☰ menu (with confirmation); the most recently deleted page can be restored there while the app is running.
- **Zoom** from 25 % to 1000 % (`Ctrl + mouse wheel`, two fingers, `Ctrl + +/-/0`), pan with the middle mouse button, `Space + drag` or one finger.
- **Pages** can be appended or inserted right after the current page, and given **page titles**; the page overview shows thumbnails or a table of contents. Page titles become bookmarks in the PDF.
- **Pages** can be appended or inserted right after the current page, and given **page titles**; the page overview shows thumbnails (including the imported PDF page) or a table of contents. Page titles become bookmarks in the PDF.
- **Toolbar** can be docked left or right (for left- and right-handed use).
### Notebooks and saving
@@ -72,7 +74,7 @@ The interface is available in English and German; ink-notes follows the system l
### Input devices
- Pens via the Wayland or X11 tablet protocol (pressure, eraser end, side button).
- While the pen touches the screen or hovers nearby, touches by the hand are ignored.
- While the pen touches the screen or hovers nearby, and shortly after, touches by the hand are ignored – also on the title bar. A hand already resting on the screen when the pen comes near is ignored until it is lifted.
- Fingers: scroll and zoom by default; drawing with a finger can be enabled in the ☰ menu.
### Updates
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+30
View File
@@ -84,6 +84,36 @@
</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>
</description>
</release>
<release version="0.34.0" date="2026-10-23">
<description>
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
</description>
</release>
<release version="0.33.0" date="2026-10-21">
<description>
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
</description>
</release>
<release version="0.32.0" date="2026-10-21">
<description>
<p>The endless canvas starts at the top left and grows to the right and down. New text fields start short and grow with the text. The toolbar buttons are larger again on small screens. On Wayland, the title bar buttons work with a finger.</p>
+156
View File
@@ -805,6 +805,19 @@
"dest": "cargo/vendor/d3d12-0.19.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/data-url/data-url-0.3.2.crate",
"sha256": "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376",
"dest": "cargo/vendor/data-url-0.3.2"
},
{
"type": "inline",
"contents": "{\"package\": \"be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376\", \"files\": {}}",
"dest": "cargo/vendor/data-url-0.3.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -1156,6 +1169,19 @@
"dest": "cargo/vendor/flate2-1.1.10",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/float-cmp/float-cmp-0.9.0.crate",
"sha256": "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4",
"dest": "cargo/vendor/float-cmp-0.9.0"
},
{
"type": "inline",
"contents": "{\"package\": \"98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4\", \"files\": {}}",
"dest": "cargo/vendor/float-cmp-0.9.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -1884,6 +1910,19 @@
"dest": "cargo/vendor/image-webp-0.2.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/imagesize/imagesize-0.15.0.crate",
"sha256": "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a",
"dest": "cargo/vendor/imagesize-0.15.0"
},
{
"type": "inline",
"contents": "{\"package\": \"65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a\", \"files\": {}}",
"dest": "cargo/vendor/imagesize-0.15.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -2794,6 +2833,19 @@
"dest": "cargo/vendor/pic-scale-0.7.12",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/pico-args/pico-args-0.5.0.crate",
"sha256": "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315",
"dest": "cargo/vendor/pico-args-0.5.0"
},
{
"type": "inline",
"contents": "{\"package\": \"5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315\", \"files\": {}}",
"dest": "cargo/vendor/pico-args-0.5.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3197,6 +3249,32 @@
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/resvg/resvg-0.49.0.crate",
"sha256": "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8",
"dest": "cargo/vendor/resvg-0.49.0"
},
{
"type": "inline",
"contents": "{\"package\": \"1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8\", \"files\": {}}",
"dest": "cargo/vendor/resvg-0.49.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/rgb/rgb-0.8.53.crate",
"sha256": "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4",
"dest": "cargo/vendor/rgb-0.8.53"
},
{
"type": "inline",
"contents": "{\"package\": \"47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4\", \"files\": {}}",
"dest": "cargo/vendor/rgb-0.8.53",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3210,6 +3288,19 @@
"dest": "cargo/vendor/ring-0.17.14",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/roxmltree/roxmltree-0.21.2.crate",
"sha256": "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907",
"dest": "cargo/vendor/roxmltree-0.21.2"
},
{
"type": "inline",
"contents": "{\"package\": \"9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907\", \"files\": {}}",
"dest": "cargo/vendor/roxmltree-0.21.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3483,6 +3574,19 @@
"dest": "cargo/vendor/simdutf8-0.1.5",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/simplecss/simplecss-0.2.2.crate",
"sha256": "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c",
"dest": "cargo/vendor/simplecss-0.2.2"
},
{
"type": "inline",
"contents": "{\"package\": \"7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c\", \"files\": {}}",
"dest": "cargo/vendor/simplecss-0.2.2",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3678,6 +3782,19 @@
"dest": "cargo/vendor/subtle-2.6.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/svgtypes/svgtypes-0.16.1.crate",
"sha256": "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d",
"dest": "cargo/vendor/svgtypes-0.16.1"
},
{
"type": "inline",
"contents": "{\"package\": \"695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d\", \"files\": {}}",
"dest": "cargo/vendor/svgtypes-0.16.1",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3873,6 +3990,19 @@
"dest": "cargo/vendor/tiny-skia-0.11.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.12.0.crate",
"sha256": "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea",
"dest": "cargo/vendor/tiny-skia-0.12.0"
},
{
"type": "inline",
"contents": "{\"package\": \"47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea\", \"files\": {}}",
"dest": "cargo/vendor/tiny-skia-0.12.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -3886,6 +4016,19 @@
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.12.0.crate",
"sha256": "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9",
"dest": "cargo/vendor/tiny-skia-path-0.12.0"
},
{
"type": "inline",
"contents": "{\"package\": \"edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9\", \"files\": {}}",
"dest": "cargo/vendor/tiny-skia-path-0.12.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
@@ -4094,6 +4237,19 @@
"dest": "cargo/vendor/url-2.5.8",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/usvg/usvg-0.49.0.crate",
"sha256": "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c",
"dest": "cargo/vendor/usvg-0.49.0"
},
{
"type": "inline",
"contents": "{\"package\": \"d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c\", \"files\": {}}",
"dest": "cargo/vendor/usvg-0.49.0",
"dest-filename": ".cargo-checksum.json"
},
{
"type": "archive",
"archive-type": "tar-gzip",
+30
View File
@@ -84,6 +84,36 @@
</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>
</description>
</release>
<release version="0.34.0" date="2026-10-23">
<description>
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
</description>
</release>
<release version="0.33.0" date="2026-10-21">
<description>
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
</description>
</release>
<release version="0.32.0" date="2026-10-21">
<description>
<p>The endless canvas starts at the top left and grows to the right and down. New text fields start short and grow with the text. The toolbar buttons are larger again on small screens. On Wayland, the title bar buttons work with a finger.</p>
+6
View File
@@ -3,6 +3,12 @@
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
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
- **Text box fix**: Ctrl+Plus/Minus now only zooms the page, no longer the whole interface as well – that made text jump to another place when leaving a text box
+4356
View File
File diff suppressed because it is too large. Load diff
+30 -15
View File
@@ -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
View File
@@ -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]
+208 -13
View File
@@ -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 {
@@ -1801,6 +1858,53 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
Some((sum_sq / points.len() as f32).sqrt())
}
/// Longest miter of a polygon corner, in half stroke widths: corners down to about 14° stay
/// pointed, only sharper ones are bevelled (Issue #41).
const MITER_LIMIT: f32 = 8.0;
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
/// edges keep the full half width `half_w` up to the corner (Issue #41): mild corners get one
/// mitered pair, sharp ones a bevel from two pairs that share the inner point.
fn polygon_corner_join(
curr: Vec2,
prev: Vec2,
next: Vec2,
n_prev: Vec2,
n_next: Vec2,
half_w: f32,
) -> Vec<[Vec2; 2]> {
let bisector = (n_prev + n_next).normalize_or_zero();
let cos_half = bisector.dot(n_prev);
if bisector == Vec2::ZERO || cos_half <= 1e-3 {
// Edge turns back on itself
return vec![[curr + n_prev * half_w, curr - n_prev * half_w]];
}
let miter = half_w / cos_half;
if miter <= half_w * MITER_LIMIT {
let offset = bisector * miter;
return vec![[curr + offset, curr - offset]];
}
// The inner point may not reach past the shorter adjacent edge
let shortest = (curr - prev).length().min((next - curr).length());
let inner_len = miter.min(shortest.max(half_w));
// Turning towards the left normal: the outer side is on the right
let turns_left = (next - curr).dot(n_prev) > 0.0;
if turns_left {
let inner = curr + bisector * inner_len;
vec![
[inner, curr - n_prev * half_w],
[inner, curr - n_next * half_w],
]
} else {
let inner = curr - bisector * inner_len;
vec![
[curr + n_prev * half_w, inner],
[curr + n_next * half_w, inner],
]
}
}
/// Erzeugt ein gleichmäßiges Triangle-Strip-Mesh für ein geschlossenes Polygon (Dreieck, Rechteck, Raute, N-Eck).
pub fn closed_polygon_to_mesh(
corners: &[Vec2],
@@ -1812,7 +1916,7 @@ pub fn closed_polygon_to_mesh(
return (Vec::new(), Vec::new());
}
let mut vertices = Vec::with_capacity((n + 1) * 2);
let mut vertices = Vec::with_capacity((2 * n + 1) * 2);
let mut samples = Vec::with_capacity(n * 8);
for i in 0..n {
@@ -1825,18 +1929,10 @@ pub fn closed_polygon_to_mesh(
let n_prev = Vec2::new(-d_prev.y, d_prev.x);
let n_next = Vec2::new(-d_next.y, d_next.x);
let corner_norm = (n_prev + n_next).normalize_or_zero() * half_w;
vertices.push(InkVertex::new(
curr.x + corner_norm.x,
curr.y + corner_norm.y,
color,
));
vertices.push(InkVertex::new(
curr.x - corner_norm.x,
curr.y - corner_norm.y,
color,
));
for [left, right] in polygon_corner_join(curr, prev, next, n_prev, n_next, half_w) {
vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(right.x, right.y, color));
}
let edge_len = (next - curr).length();
let steps = ((edge_len / 10.0).ceil() as usize).max(2);
@@ -2288,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
@@ -2481,6 +2649,33 @@ mod tests {
);
}
#[test]
fn test_polygon_edges_keep_full_width() {
// Right and acute corners: no outline vertex comes closer than half a stroke width to
// the outline, so all three sides look equally thick (Issue #41); mitered corners
// reach at most MITER_LIMIT half widths out
let half_w = 10.0;
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 300.0),
Vec2::new(300.0, 300.0),
];
let (_, vertices) = closed_polygon_to_mesh(&corners, half_w, [0.0, 0.0, 0.0, 1.0]);
assert!(vertices.len() > 2 * corners.len());
for v in &vertices {
let p = Vec2::new(v.position[0], v.position[1]);
let nearest = (0..3)
.map(|i| {
crate::document::point_to_segment_distance(p, corners[i], corners[(i + 1) % 3])
})
.fold(f32::MAX, f32::min);
assert!(
nearest > half_w - 0.01 && nearest < half_w * MITER_LIMIT + 0.01,
"{p:?} is {nearest} from the outline"
);
}
}
#[test]
fn test_detect_triangle() {
let mut points = Vec::new();
+4 -1
View File
@@ -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
+21 -8
View File
@@ -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"),
@@ -120,6 +119,7 @@ pub const EN: &[(&str, &str)] = &[
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
("Gitea Server-URL:", "Gitea server URL:"),
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
("GRÖSSE", "SIZE"),
("Graphit-Grau", "Graphite gray"),
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
("Grün", "Green"),
@@ -149,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."),
@@ -233,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…"),
@@ -255,8 +252,11 @@ pub const EN: &[(&str, &str)] = &[
("Quell-PDF fehlt", "Source PDF missing"),
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
("Radieren", "Erase"),
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker", "Eraser (E or right mouse button) – below: whole strokes, partial stroke or highlighter only"),
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
("Radierer", "Eraser"),
("Radierer (Eraser)", "Eraser"),
("Radiergröße {size} mm – tippen zum Ändern", "Eraser size {size} mm – tap to change"),
("Radiergröße {size} setzen", "Set eraser size to {size}"),
("Raute (Rhombus)", "Rhombus"),
("Rechteck", "Rectangle"),
("Rechts", "Right"),
@@ -312,6 +312,7 @@ pub const EN: &[(&str, &str)] = &[
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
("Stift {number}", "Pen {number}"),
("Stift", "Pen"),
("STUFENLOSE GRÖSSE", "STEPLESS SIZE"),
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
("Stärke auf {width} setzen", "Set width to {width}"),
("System", "System"),
@@ -363,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"),
@@ -425,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"),
@@ -449,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
View File
@@ -0,0 +1,257 @@
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
//! (`xopp`) and Rnote (`rnote`). This module holds what the importers share: building
//! strokes, texts and images in the stored format and creating the notebook.
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::document::{Notebook, Page, PageBackground, StrokeTool};
use crate::storage::{self, NotebookLayout, NotebookManifest, SampleData, StrokeData};
use crate::t;
use crate::text::{TextBox, TextFont};
/// Largest accepted file after decompression (guards against gzip bombs).
const MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters).
pub const HIGHLIGHTER_ALPHA: f32 = 0.35;
/// What could not be taken over.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImportReport {
pub skipped: usize,
/// The PDF background was referenced but not found or unreadable
pub missing_pdf: Option<String>,
}
/// Result of an importer: pages, the next free stroke id, the layout (`None`: default).
pub struct Imported {
pub pages: Vec<Page>,
pub next_stroke_id: u64,
pub layout: Option<NotebookLayout>,
pub report: ImportReport,
}
/// Can the file be imported (by its name)?
pub fn is_importable_path(path: &Path) -> bool {
crate::file_picker::is_pdf_path(path)
|| crate::xopp::is_xopp_path(path)
|| crate::rnote::is_rnote_path(path)
}
/// Imports a PDF, Xournal++ or Rnote file as a new notebook titled `title`.
pub fn import_file(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
if crate::xopp::is_xopp_path(path) {
crate::xopp::create_notebook_from_xopp(base_dir, title, path)
} else if crate::rnote::is_rnote_path(path) {
crate::rnote::create_notebook_from_rnote(base_dir, title, path)
} else {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
storage::create_notebook_from_pdf(base_dir, title, bytes)
.map(|(nb, manifest, dir)| (nb, manifest, dir, ImportReport::default()))
}
}
/// Decompresses gzip data; other data is returned unchanged.
pub fn gunzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
if !bytes.starts_with(&[0x1f, 0x8b]) {
return Ok(bytes.to_vec());
}
let mut data = Vec::new();
flate2::read::MultiGzDecoder::new(bytes)
.take(MAX_UNCOMPRESSED_BYTES)
.read_to_end(&mut data)
.map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?;
Ok(data)
}
/// Creates a new notebook and fills it with the pages `build` creates in its folder (images
/// and PDFs go to its asset folder). On failure nothing is left behind.
pub fn create_notebook(
base_dir: &Path,
title: &str,
build: impl FnOnce(&Path) -> Result<Imported, String>,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
let result = (|| {
let imported = build(&dir)?;
let page_count = imported.pages.len() as u64;
let notebook =
Notebook::from_pages(imported.pages, 0, imported.next_stroke_id, page_count + 1);
let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?;
if imported.layout.is_some() {
manifest.layout = imported.layout;
}
storage::save_notebook(&dir, &notebook, &mut manifest).map_err(|e| e.to_string())?;
Ok((notebook, manifest, imported.report))
})();
match result {
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
Err(err) => {
let _ = std::fs::remove_dir_all(&dir);
Err(err)
}
}
}
/// Builds a page from stored-format strokes.
pub fn page(
id: u64,
size: glam::Vec2,
background: PageBackground,
pdf_background: Option<crate::document::PdfBackground>,
strokes: Vec<StrokeData>,
) -> Page {
storage::PageData {
format_version: storage::CURRENT_FORMAT_VERSION,
id,
background,
pdf_background,
title: None,
size: (size != crate::document::A4_SIZE).then(|| size.to_array()),
strokes,
}
.to_page()
}
/// Our stroke width and the sample pressures for absolute widths per point (page units):
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
/// ones less (down to a third of the widest).
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
let width = widest / 1.5;
let pressures = widths
.iter()
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
.collect();
(width, pressures)
}
/// Pen or highlighter stroke through `points` (page units) with the given widths per point.
pub fn ink_stroke(
id: u64,
mut color: [f32; 4],
highlighter: bool,
points: &[glam::Vec2],
width: f32,
pressures: &[f32],
) -> StrokeData {
let tool = if highlighter {
if color[3] >= 0.99 {
color[3] = HIGHLIGHTER_ALPHA;
}
StrokeTool::Highlighter
} else {
// Pens are always opaque (translucent pens are read as highlighters)
color[3] = 1.0;
StrokeTool::Pen
};
let samples = points
.iter()
.zip(pressures)
.enumerate()
.map(|(i, (p, &pressure))| SampleData {
x: p.x,
y: p.y,
p: pressure,
t: i as f64 * 0.005,
})
.collect();
StrokeData {
id,
color,
samples,
shape: None,
tool,
width,
finalized: true,
image: None,
text: None,
}
}
/// Text field with its upper left corner at `origin` and the axes (page vector per unit to
/// the right and down). Without `wrap_width` it is as wide as its longest line.
#[allow(clippy::too_many_arguments)]
pub fn text_stroke(
id: u64,
content: &str,
size: f32,
font: TextFont,
origin: glam::Vec2,
axes: [glam::Vec2; 2],
wrap_width: Option<f32>,
mut color: [f32; 4],
) -> StrokeData {
let wrap_width = wrap_width.unwrap_or_else(|| {
let unwrapped = TextBox::new(content.to_string(), size, font, f32::MAX / 4.0);
let widest = unwrapped
.layout()
.lines
.iter()
.map(|line| line.width)
.fold(0.0, f32::max);
widest + size
});
let text = TextBox::new(content.to_string(), size, font, wrap_width);
let corners = crate::text::frame_corners(origin, axes, text.box_size());
color[3] = 1.0;
StrokeData {
id,
color,
samples: corner_samples(corners),
shape: None,
tool: StrokeTool::Pen,
width: 0.0,
finalized: true,
image: None,
text: Some(text),
}
}
/// Image with the corners upper left, upper right, lower right, lower left.
pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData {
StrokeData {
id,
color: [1.0, 1.0, 1.0, 1.0],
samples: corner_samples(corners),
shape: None,
tool: StrokeTool::Pen,
width: 0.0,
finalized: true,
image: Some(asset),
text: None,
}
}
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
corners
.iter()
.map(|c| SampleData {
x: c.x,
y: c.y,
p: 1.0,
t: 0.0,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fit_widths_keeps_the_drawn_widths() {
let (width, pressures) = fit_widths(&[30.0, 15.0, 10.0, 2.0]);
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
assert!((drawn[0] - 30.0).abs() < 1e-3);
assert!((drawn[1] - 15.0).abs() < 1e-3);
assert!((drawn[2] - 10.0).abs() < 1e-3);
// Thinner than a third of the widest point is not possible
assert!((drawn[3] - 10.0).abs() < 1e-3);
}
}
+4
View File
@@ -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 -4130
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -21,6 +21,8 @@ use crate::t;
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
/// Longest edge of a page picker thumbnail (sharp at display scale 2).
pub const THUMBNAIL_RENDER_PIXELS: u32 = 256;
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
+938
View File
@@ -0,0 +1,938 @@
//! Import of Rnote notebooks (.rnote) (Issue #47).
//!
//! An .rnote file is gzip-compressed JSON: `{"version": "0.x.y", "data": {"engine_snapshot":
//! {...}}}`. The snapshot is Rnote's internal state without a specification, so it is read
//! leniently as JSON and only what we need is picked out; files from Rnote 0.5.10 on are
//! supported (the layout of older ones differs too much). Coordinates are pixels at the
//! document's dpi.
//!
//! Pages: fixed-size and continuous documents become one page per Rnote page (a stroke goes
//! to the page its middle lies on), infinite documents an endless notebook.
use base64::Engine;
use glam::DVec2;
use serde_json::Value;
use std::path::Path;
use crate::document::{Notebook, PageBackground};
use crate::import::{self, ImportReport, Imported};
use crate::storage::{NotebookLayout, NotebookManifest, StrokeData};
use crate::t;
use crate::text::TextFont;
/// At most this many pages are created (protects against nonsensical document sizes).
const MAX_PAGES: usize = 2000;
/// Is the file by its name an Rnote notebook?
pub fn is_rnote_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("rnote"))
}
/// Parsed Rnote document, still in Rnote pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct RnoteDocument {
/// Page size in pixels and dpi
pub format: DVec2,
pub dpi: f64,
/// Document bounds (upper left corner and size)
pub origin: DVec2,
pub size: DVec2,
pub endless: bool,
pub background: PageBackground,
/// In drawing order
pub items: Vec<RnoteItem>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RnoteItem {
Ink {
color: [f32; 4],
highlighter: bool,
points: Vec<DVec2>,
/// Drawn width per point
widths: Vec<f64>,
},
Text {
content: String,
size: f64,
mono: bool,
/// Upper left corner and axes
affine: [DVec2; 3],
max_width: Option<f64>,
color: [f32; 4],
},
Bitmap {
width: u32,
height: u32,
/// Straight (not premultiplied) RGBA
pixels: Vec<u8>,
corners: [DVec2; 4],
},
/// SVG image (Rnote stores imported PDF pages like this), rasterized on import
Svg { svg: String, corners: [DVec2; 4] },
/// Element without a counterpart or unreadable
Unsupported,
}
impl RnoteItem {
/// Point that decides which page the element belongs to.
fn anchor(&self) -> Option<DVec2> {
match self {
RnoteItem::Ink { points, .. } => {
let (min, max) = points.iter().fold(
(DVec2::splat(f64::MAX), DVec2::splat(f64::MIN)),
|(a, b), &p| (a.min(p), b.max(p)),
);
(!points.is_empty()).then(|| (min + max) / 2.0)
}
RnoteItem::Text { affine, .. } => Some(affine[2]),
RnoteItem::Bitmap { corners, .. } | RnoteItem::Svg { corners, .. } => {
Some((corners[0] + corners[2]) / 2.0)
}
RnoteItem::Unsupported => None,
}
}
}
fn num(value: &Value, key: &str) -> Option<f64> {
value.get(key)?.as_f64().filter(|v| v.is_finite())
}
/// Vectors are stored as `[x, y]` (older versions possibly as `{"x": .., "y": ..}`).
fn vec2(value: &Value) -> Option<DVec2> {
match value {
Value::Array(a) if a.len() == 2 => Some(DVec2::new(a[0].as_f64()?, a[1].as_f64()?)),
Value::Object(_) => Some(DVec2::new(num(value, "x")?, num(value, "y")?)),
_ => None,
}
.filter(|v| v.is_finite())
}
/// Affine transformation as x axis, y axis and translation. Since Rnote 0.15 `affine` holds
/// the six values; before, `transform.affine` held a column-major 3×3 matrix.
fn affine(value: &Value) -> Option<[DVec2; 3]> {
let values = value
.get("affine")
.or_else(|| value.get("transform")?.get("affine"))?
.as_array()?;
let v: Vec<f64> = values.iter().filter_map(Value::as_f64).collect();
let [a, b, c, d, e, f] = match v.len() {
6 => [v[0], v[1], v[2], v[3], v[4], v[5]],
9 => [v[0], v[1], v[3], v[4], v[6], v[7]],
_ => return None,
};
Some([DVec2::new(a, b), DVec2::new(c, d), DVec2::new(e, f)])
}
fn apply(affine: &[DVec2; 3], p: DVec2) -> DVec2 {
affine[0] * p.x + affine[1] * p.y + affine[2]
}
fn color(value: Option<&Value>) -> Option<[f32; 4]> {
let value = value.filter(|v| v.is_object())?;
let c = |k| num(value, k).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
Some([
c("r"),
c("g"),
c("b"),
num(value, "a").map_or(1.0, |a| a.clamp(0.0, 1.0) as f32),
])
}
/// The one variant of an externally tagged enum: `{"name": {...}}`.
fn variant(value: &Value) -> Option<(&str, &Value)> {
let object = value.as_object()?;
let (name, inner) = object.iter().next()?;
Some((name.as_str(), inner))
}
/// Width for the pressure according to Rnote's pressure curve.
fn curve_width(curve: &str, width: f64, pressure: f64) -> f64 {
let p = pressure.clamp(0.0, 1.0);
match curve {
"const" => width,
"sqrt" => width * p.sqrt(),
"cbrt" => width * p.cbrt(),
"pow2" => width * p.powi(2),
"pow3" => width * p.powi(3),
_ => width * p,
}
}
/// Style options (smooth, rough or textured).
struct Style {
width: f64,
stroke_color: Option<[f32; 4]>,
fill_color: Option<[f32; 4]>,
pressure_curve: String,
}
fn style(value: Option<&Value>) -> Option<Style> {
let (kind, options) = variant(value?)?;
let curve = if kind == "rough" {
"const".to_string()
} else {
options
.get("pressure_curve")
.and_then(Value::as_str)
.unwrap_or("linear")
.to_string()
};
Some(Style {
width: num(options, "stroke_width").unwrap_or(1.0).max(0.1),
stroke_color: color(options.get("stroke_color")),
fill_color: color(options.get("fill_color")),
pressure_curve: curve,
})
}
/// Points along a Bézier curve (without its start), at most every 2 pixels; for a line
/// just its end.
fn bezier(start: DVec2, controls: &[DVec2], end: DVec2) -> Vec<DVec2> {
if controls.is_empty() {
return vec![end];
}
let length = controls
.iter()
.chain([&end])
.fold((0.0, start), |(sum, prev), &p| (sum + prev.distance(p), p))
.0;
let steps = ((length / 2.0).ceil() as usize).clamp(1, 32);
(1..=steps)
.map(|i| {
let t = i as f64 / steps as f64;
let u = 1.0 - t;
match controls {
[cp] => start * u * u + *cp * 2.0 * u * t + end * t * t,
[cp1, cp2] => {
start * u * u * u
+ *cp1 * 3.0 * u * u * t
+ *cp2 * 3.0 * u * t * t
+ end * t * t * t
}
_ => start + (end - start) * t,
}
})
.collect()
}
fn element(value: &Value) -> Option<(DVec2, f64)> {
Some((
vec2(value.get("pos")?)?,
num(value, "pressure").unwrap_or(0.5),
))
}
fn brushstroke(value: &Value, highlighter: bool) -> Option<RnoteItem> {
let style = style(value.get("style"))?;
let color = style.stroke_color?;
let path = value.get("path")?;
let (mut prev, mut prev_pressure) = element(path.get("start")?)?;
let mut points = vec![prev];
let mut pressures = vec![prev_pressure];
for segment in path.get("segments")?.as_array()? {
let Some((kind, seg)) = variant(segment) else {
continue;
};
let Some((end, end_pressure)) = seg.get("end").and_then(element) else {
continue;
};
let controls: Vec<DVec2> = match kind {
"quadbezto" | "quadbez" => seg.get("cp").and_then(vec2).into_iter().collect(),
"cubbezto" | "cubbez" => [seg.get("cp1"), seg.get("cp2")]
.into_iter()
.filter_map(|v| v.and_then(vec2))
.collect(),
_ => Vec::new(),
};
let new = bezier(prev, &controls, end);
let n = new.len() as f64;
for (i, p) in new.into_iter().enumerate() {
let t = (i + 1) as f64 / n;
points.push(p);
pressures.push(prev_pressure + (end_pressure - prev_pressure) * t);
}
prev = end;
prev_pressure = end_pressure;
}
let widths = pressures
.iter()
.map(|&p| curve_width(&style.pressure_curve, style.width, p))
.collect();
Some(RnoteItem::Ink {
highlighter: highlighter || color[3] < 0.99,
color,
points,
widths,
})
}
/// Outline of a shape as polylines.
fn shape_lines(shape: &Value) -> Option<Vec<Vec<DVec2>>> {
let (kind, s) = variant(shape)?;
let v = |k: &str| s.get(k).and_then(vec2);
let lines = match kind {
"line" => vec![vec![v("start")?, v("end")?]],
"arrow" => {
let (start, tip) = (v("start")?, v("tip")?);
let back = (start - tip).normalize_or_zero();
let length = (start.distance(tip) * 0.4).min(20.0);
let wing = |angle: f64| tip + DVec2::from_angle(angle).rotate(back) * length;
vec![vec![start, tip], vec![wing(0.5), tip, wing(-0.5)]]
}
"rect" => {
let half = vec2(s.get("cuboid")?.get("half_extents")?)?;
let affine = affine(s)?;
let corners = [
(-1.0, -1.0),
(1.0, -1.0),
(1.0, 1.0),
(-1.0, 1.0),
(-1.0, -1.0),
];
vec![corners
.iter()
.map(|&(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y)))
.collect()]
}
"ellipse" => {
let radii = v("radii")?;
let affine = affine(s)?;
vec![(0..=64)
.map(|i| {
let a = i as f64 / 64.0 * std::f64::consts::TAU;
apply(&affine, DVec2::new(a.cos() * radii.x, a.sin() * radii.y))
})
.collect()]
}
"quadbez" | "cubbez" => {
let start = v("start")?;
let controls: Vec<DVec2> = ["cp", "cp1", "cp2"].iter().filter_map(|k| v(k)).collect();
let mut points = vec![start];
points.extend(bezier(start, &controls, v("end")?));
vec![points]
}
"polyline" | "polygon" => {
let start = v("start")?;
let mut points = vec![start];
points.extend(s.get("path")?.as_array()?.iter().filter_map(vec2));
if kind == "polygon" {
points.push(start);
}
vec![points]
}
_ => return None,
};
Some(lines)
}
fn shapestroke(value: &Value, highlighter: bool) -> Vec<RnoteItem> {
let Some(Style {
width,
stroke_color,
fill_color,
..
}) = style(value.get("style"))
else {
return vec![RnoteItem::Unsupported];
};
let (Some(color), Some(lines)) = (
stroke_color.or(fill_color),
value.get("shape").and_then(shape_lines),
) else {
return vec![RnoteItem::Unsupported];
};
lines
.into_iter()
.map(|points| RnoteItem::Ink {
color,
highlighter: highlighter || color[3] < 0.99,
widths: vec![width; points.len()],
points,
})
.collect()
}
fn textstroke(value: &Value) -> Option<RnoteItem> {
let style = value.get("text_style")?;
Some(RnoteItem::Text {
content: value.get("text")?.as_str()?.to_string(),
size: num(style, "font_size").unwrap_or(32.0).max(1.0),
mono: style
.get("font_family")
.and_then(Value::as_str)
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
affine: affine(value)?,
max_width: num(style, "max_width").filter(|w| *w > 0.0),
color: color(style.get("color")).unwrap_or([0.0, 0.0, 0.0, 1.0]),
})
}
fn bitmapimage(value: &Value) -> Option<RnoteItem> {
let image = value.get("image")?;
let width = num(image, "pixel_width")? as u32;
let height = num(image, "pixel_height")? as u32;
let mut pixels = base64::engine::general_purpose::STANDARD
.decode(image.get("data")?.as_str()?)
.ok()?;
if pixels.len() != width as usize * height as usize * 4 {
return None;
}
unpremultiply(&mut pixels);
Some(RnoteItem::Bitmap {
width,
height,
pixels,
corners: rectangle_corners(value.get("rectangle")?)?,
})
}
fn vectorimage(value: &Value) -> Option<RnoteItem> {
Some(RnoteItem::Svg {
svg: value.get("svg_data")?.as_str()?.to_string(),
corners: rectangle_corners(value.get("rectangle")?)?,
})
}
/// Corners (upper left, upper right, lower right, lower left) of an Rnote rectangle: a
/// box around the origin, placed by its affine transformation.
fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
let half = vec2(rectangle.get("cuboid")?.get("half_extents")?)?;
let affine = affine(rectangle)?;
Some(
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.map(|(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y))),
)
}
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
fn unpremultiply(pixels: &mut [u8]) {
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
use resvg::{tiny_skia, usvg};
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
let px_per_unit = 250.0 / 254.0;
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
let transform = tiny_skia::Transform::from_scale(
width as f32 / tree.size().width(),
height as f32 / tree.size().height(),
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut pixels = pixmap.take();
unpremultiply(&mut pixels);
Some((width, height, pixels))
}
/// Rnote's version as (major, minor, patch).
fn version(text: &str) -> Option<(u64, u64, u64)> {
let v = semver::Version::parse(text).ok()?;
Some((v.major, v.minor, v.patch))
}
/// Parses the (decompressed) JSON of an .rnote file.
pub fn parse(json: &[u8]) -> Result<RnoteDocument, String> {
let not_rnote = || t!("Keine Rnote-Datei").to_string();
let wrapper: Value = serde_json::from_slice(json).map_err(|_| not_rnote())?;
let version_text = wrapper
.get("version")
.and_then(Value::as_str)
.ok_or_else(not_rnote)?;
if version(version_text).is_none_or(|v| v < (0, 5, 10)) {
return Err(t!(
"Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern",
version = version_text
)
.to_string());
}
let snapshot = wrapper
.get("data")
.and_then(|d| d.get("engine_snapshot"))
.ok_or_else(not_rnote)?;
let document = snapshot.get("document").ok_or_else(not_rnote)?;
// Since Rnote 0.13 format, background and layout are in `config`
let config = document.get("config").unwrap_or(document);
let format = config.get("format").unwrap_or(&Value::Null);
let dpi = num(format, "dpi").filter(|d| *d > 0.0).unwrap_or(96.0);
let format_size = DVec2::new(
num(format, "width").unwrap_or(dpi * 210.0 / 25.4).max(1.0),
num(format, "height").unwrap_or(dpi * 297.0 / 25.4).max(1.0),
);
let background = match config
.get("background")
.and_then(|b| b.get("pattern"))
.and_then(Value::as_str)
{
Some("lines") => PageBackground::Lined,
Some("grid" | "isometric_grid") => PageBackground::Grid5mm,
Some("dots" | "isometric_dots") => PageBackground::DotGrid,
_ => PageBackground::Blank,
};
let endless = matches!(
config.get("layout").and_then(Value::as_str),
Some("infinite" | "semi_infinite")
);
let strokes = snapshot
.get("stroke_components")
.and_then(Value::as_array)
.ok_or_else(not_rnote)?;
let chrono = snapshot
.get("chrono_components")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
// Slot maps are stored as slot arrays; the chrono components share the slot index
let mut ordered: Vec<(u64, Vec<RnoteItem>)> = Vec::new();
for (index, slot) in strokes.iter().enumerate() {
let Some(stroke) = slot.get("value").filter(|v| !v.is_null()) else {
continue;
};
let chrono = chrono.get(index).and_then(|c| c.get("value"));
let t = chrono.and_then(|c| c.get("t")).and_then(Value::as_u64);
let highlighter = chrono
.and_then(|c| c.get("layer"))
.and_then(Value::as_str)
.is_some_and(|l| l.eq_ignore_ascii_case("highlighter"));
let items = match variant(stroke) {
Some(("brushstroke", v)) => {
vec![brushstroke(v, highlighter).unwrap_or(RnoteItem::Unsupported)]
}
Some(("shapestroke", v)) => shapestroke(v, highlighter),
Some(("textstroke", v)) => vec![textstroke(v).unwrap_or(RnoteItem::Unsupported)],
Some(("bitmapimage", v)) => vec![bitmapimage(v).unwrap_or(RnoteItem::Unsupported)],
Some(("vectorimage", v)) => vec![vectorimage(v).unwrap_or(RnoteItem::Unsupported)],
_ => vec![RnoteItem::Unsupported],
};
ordered.push((t.unwrap_or(index as u64), items));
}
ordered.sort_by_key(|(t, _)| *t);
Ok(RnoteDocument {
format: format_size,
dpi,
origin: DVec2::new(
num(document, "x").unwrap_or(0.0),
num(document, "y").unwrap_or(0.0),
),
size: DVec2::new(
num(document, "width").unwrap_or(format_size.x),
num(document, "height").unwrap_or(format_size.y),
),
endless,
background,
items: ordered.into_iter().flat_map(|(_, items)| items).collect(),
})
}
/// Creates a new notebook from an Rnote file.
pub fn create_notebook_from_rnote(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, std::path::PathBuf, ImportReport), String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let doc = parse(&import::gunzip(&bytes)?)?;
import::create_notebook(base_dir, title, |dir| Ok(convert(&doc, dir)))
}
/// A target page: where its Rnote page starts, and how Rnote pixels map onto it.
struct Target {
rnote_origin: DVec2,
origin: glam::Vec2,
scale: f32,
size: glam::Vec2,
strokes: Vec<StrokeData>,
}
impl Target {
fn map(&self, p: DVec2) -> glam::Vec2 {
self.origin + (p - self.rnote_origin).as_vec2() * self.scale
}
}
fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
// Page units per Rnote pixel at the document's dpi
let units_per_px = (crate::document::UNITS_PER_MM as f64 * 25.4 / doc.dpi) as f32;
let (columns, mut targets) = if doc.endless {
let target = Target {
rnote_origin: DVec2::ZERO,
origin: crate::document::ENDLESS_HOME,
scale: units_per_px,
size: crate::document::ENDLESS_PAGE_SIZE,
strokes: Vec::new(),
};
(1, vec![target])
} else {
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
// aspect ratio, centred horizontally and aligned at the top
let size_pt = (doc.format / doc.dpi * 72.0).as_vec2();
let size = crate::document::pdf_page_size(size_pt.x, size_pt.y);
let [origin, top_right, _, bottom_left] =
crate::document::pdf_placement(size_pt.x, size_pt.y, size);
let scale_pt =
((top_right.x - origin.x) / size_pt.x).min((bottom_left.y - origin.y) / size_pt.y);
let scale = scale_pt * (72.0 / doc.dpi) as f32;
// Rnote grows documents page by page; tolerate rounding in the stored size
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
let columns = count(doc.size.x, doc.format.x);
let rows = count(doc.size.y, doc.format.y);
let rows = rows.min((MAX_PAGES / columns).max(1));
let targets = (0..rows * columns)
.map(|i| Target {
rnote_origin: doc.origin
+ DVec2::new((i % columns) as f64, (i / columns) as f64) * doc.format,
origin,
scale,
size,
strokes: Vec::new(),
})
.collect();
(columns, targets)
};
let rows = targets.len() / columns;
let mut next_id: u64 = 1;
for item in &doc.items {
let Some(anchor) = item.anchor() else {
report.skipped += 1;
continue;
};
let cell = ((anchor - doc.origin) / doc.format).floor();
let index = if doc.endless {
0
} else {
let column = (cell.x.max(0.0) as usize).min(columns - 1);
let row = (cell.y.max(0.0) as usize).min(rows - 1);
row * columns + column
};
let target = &mut targets[index];
let id = next_id;
next_id += 1;
let stroke = match item {
RnoteItem::Ink {
color,
highlighter,
points,
widths,
} => {
let points: Vec<glam::Vec2> = points.iter().map(|&p| target.map(p)).collect();
let widths: Vec<f32> = widths.iter().map(|&w| w as f32 * target.scale).collect();
let (width, pressures) = import::fit_widths(&widths);
import::ink_stroke(id, *color, *highlighter, &points, width, &pressures)
}
RnoteItem::Text {
content,
size,
mono,
affine,
max_width,
color,
} => {
let font = if *mono {
TextFont::Mono
} else {
TextFont::Sans
};
import::text_stroke(
id,
content,
*size as f32 * target.scale,
font,
target.map(affine[2]),
[affine[0].as_vec2(), affine[1].as_vec2()],
max_width.map(|w| w as f32 * target.scale),
*color,
)
}
RnoteItem::Bitmap {
width,
height,
pixels,
corners,
} => match assets.import_rgba(*width, *height, pixels.clone()) {
Ok(image) => import::image_stroke(id, image.asset, corners.map(|c| target.map(c))),
Err(_) => {
report.skipped += 1;
continue;
}
},
RnoteItem::Svg { svg, corners } => {
let corners = corners.map(|c| target.map(c));
let size = glam::Vec2::new(
corners[0].distance(corners[1]),
corners[0].distance(corners[3]),
);
let image = rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
assets.import_rgba(width, height, pixels).ok()
});
match image {
Some(image) => import::image_stroke(id, image.asset, corners),
None => {
report.skipped += 1;
continue;
}
}
}
RnoteItem::Unsupported => unreachable!("has no anchor"),
};
target.strokes.push(stroke);
}
let pages = targets
.into_iter()
.enumerate()
.map(|(i, target)| {
import::page(
i as u64 + 1,
target.size,
doc.background,
None,
target.strokes,
)
})
.collect();
Imported {
pages,
next_stroke_id: next_id,
layout: doc.endless.then_some(NotebookLayout::Endless),
report,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StrokeTool;
use serde_json::json;
use std::io::Write;
fn slot(value: Value) -> Value {
json!({ "value": value, "version": 1 })
}
/// A small Rnote 0.15 document: two A4 pages at 96 dpi.
fn sample(layout: &str) -> Value {
let brush = json!({ "brushstroke": {
"path": {
"start": { "pos": [100.0, 100.0], "pressure": 0.5 },
"segments": [
{ "lineto": { "end": { "pos": [120.0, 100.0], "pressure": 1.0 } } },
{ "cubbezto": { "cp1": [130.0, 110.0], "cp2": [140.0, 110.0],
"end": { "pos": [150.0, 100.0], "pressure": 0.25 } } }
]
},
"style": { "smooth": { "stroke_width": 4.0,
"stroke_color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 },
"fill_color": null, "pressure_curve": "linear" } }
}});
let rect = json!({ "shapestroke": {
"shape": { "rect": { "cuboid": { "half_extents": [50.0, 20.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 300.0, 1300.0] } },
"style": { "rough": { "stroke_width": 2.0,
"stroke_color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } } }
}});
let text = json!({ "textstroke": {
"text": "Hallo",
"affine": [1.0, 0.0, 0.0, 1.0, 50.0, 60.0],
"text_style": { "font_family": "serif", "font_size": 32.0, "max_width": null,
"color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } }
}});
let pixels = base64::engine::general_purpose::STANDARD.encode([128u8, 0, 0, 128].repeat(4));
let bitmap = json!({ "bitmapimage": {
"image": { "data": pixels, "pixel_width": 2, "pixel_height": 2,
"memory_format": "R8g8b8a8Premultiplied" },
"rectangle": { "cuboid": { "half_extents": [10.0, 10.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 400.0] }
}});
let svg = json!({ "vectorimage": {
"svg_data": "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10' fill='green'/></svg>",
"intrinsic_size": [10.0, 10.0],
"rectangle": { "cuboid": { "half_extents": [40.0, 40.0] },
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 1500.0] }
}});
let marker = json!({ "brushstroke": {
"path": { "start": { "pos": [10.0, 10.0], "pressure": 1.0 },
"segments": [ { "lineto": { "end": { "pos": [90.0, 10.0], "pressure": 1.0 } } } ] },
"style": { "smooth": { "stroke_width": 12.0,
"stroke_color": { "r": 1.0, "g": 1.0, "b": 0.0, "a": 1.0 },
"fill_color": null, "pressure_curve": "const" } }
}});
let chrono = |t: u32, layer: &str| slot(json!({ "t": t, "layer": layer }));
json!({
"version": "0.15.0",
"data": { "engine_snapshot": {
"document": {
"config": {
"format": { "width": 793.701, "height": 1122.52, "dpi": 96.0 },
"background": { "pattern": "grid" },
"layout": layout
},
"x": 0.0, "y": 0.0, "width": 793.701, "height": 2245.04
},
"camera": {},
// Slot 0 is the slot map's unused sentinel; the text was drawn first
"stroke_components": [
{ "value": null, "version": 0 },
slot(brush), slot(rect), slot(text), slot(bitmap), slot(svg), slot(marker)
],
"chrono_components": [
{ "value": null, "version": 0 },
chrono(2, "user_layer"), chrono(3, "user_layer"), chrono(1, "user_layer"),
chrono(4, "image"), chrono(5, "image"), chrono(6, "highlighter")
],
"chrono_counter": 7
}}
})
}
fn write_rnote(dir: &Path, value: &Value) -> std::path::PathBuf {
let path = dir.join("Skizze.rnote");
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(value.to_string().as_bytes()).unwrap();
std::fs::write(&path, encoder.finish().unwrap()).unwrap();
path
}
#[test]
fn test_parse_rnote() {
let doc = parse(sample("fixed_size").to_string().as_bytes()).unwrap();
assert_eq!(doc.background, PageBackground::Grid5mm);
assert!(!doc.endless);
// In drawing order: the text was drawn first
assert!(matches!(doc.items[0], RnoteItem::Text { .. }));
match &doc.items[1] {
RnoteItem::Ink {
points,
widths,
color,
highlighter,
} => {
assert!(!highlighter);
assert_eq!(*color, [0.0, 0.0, 1.0, 1.0]);
assert_eq!(points.first(), Some(&DVec2::new(100.0, 100.0)));
assert_eq!(points.last(), Some(&DVec2::new(150.0, 100.0)));
assert!(points.len() > 3, "the curve is sampled");
assert_eq!(widths[0], 2.0);
assert_eq!(widths[1], 4.0);
assert_eq!(*widths.last().unwrap(), 1.0);
}
other => panic!("expected ink, got {other:?}"),
}
match &doc.items[2] {
RnoteItem::Ink { points, .. } => {
assert_eq!(points.len(), 5);
assert_eq!(points[0], DVec2::new(250.0, 1280.0));
assert_eq!(points[2], DVec2::new(350.0, 1320.0));
}
other => panic!("expected the rectangle, got {other:?}"),
}
match &doc.items[3] {
RnoteItem::Bitmap { pixels, .. } => assert_eq!(&pixels[..4], &[255, 0, 0, 128]),
other => panic!("expected a bitmap, got {other:?}"),
}
assert!(matches!(doc.items[4], RnoteItem::Svg { .. }));
assert!(matches!(
doc.items[5],
RnoteItem::Ink {
highlighter: true,
..
}
));
}
#[test]
fn test_older_formats() {
// Before 0.13: format, background and layout directly in the document; before 0.15:
// transformations as column-major 3×3 matrix
let mut value = sample("continuous_vertical");
value["version"] = json!("0.9.4");
let document = &mut value["data"]["engine_snapshot"]["document"];
let config = document["config"].take();
for key in ["format", "background", "layout"] {
document[key] = config[key].clone();
}
document.as_object_mut().unwrap().remove("config");
let text =
&mut value["data"]["engine_snapshot"]["stroke_components"][3]["value"]["textstroke"];
text.as_object_mut().unwrap().remove("affine");
text["transform"] = json!({ "affine": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 1.0] });
let doc = parse(value.to_string().as_bytes()).unwrap();
assert_eq!(doc.background, PageBackground::Grid5mm);
match &doc.items[0] {
RnoteItem::Text { affine, .. } => assert_eq!(affine[2], DVec2::new(50.0, 60.0)),
other => panic!("expected a text, got {other:?}"),
}
value["version"] = json!("0.5.5");
assert!(parse(value.to_string().as_bytes()).is_err());
assert!(parse(b"{\"a\": 1}").is_err());
}
#[test]
fn test_create_notebook_from_rnote() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = write_rnote(source_dir.path(), &sample("fixed_size"));
let (notebook, manifest, dir, report) =
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
assert_eq!(report, ImportReport::default());
assert_eq!(manifest.layout, NotebookManifest::new("x", "x").layout);
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
assert_eq!(notebook.pages.len(), 2);
let [first, second] = &reloaded.pages[..] else {
panic!("two pages expected");
};
assert_eq!(first.background, PageBackground::Grid5mm);
// Text, pen, bitmap, highlighter on page 1; rectangle and SVG on page 2
assert_eq!(first.strokes.len(), 4);
assert_eq!(second.strokes.len(), 2);
assert!(first.strokes[0].is_text());
let pen = &first.strokes[1];
assert_eq!(pen.tool, StrokeTool::Pen);
// 96 dpi: 100 px = 26.46 mm
assert!((pen.points[0].position.x - 264.6).abs() < 0.5);
assert!(first.strokes[2].is_image());
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
// The rectangle sits on page 2 in page coordinates: 250 px - 0 / 1280 px - 1122.52 px
let rect = &second.strokes[0];
assert!((rect.points[0].position.x - 661.5).abs() < 0.5);
assert!((rect.points[0].position.y - 416.7).abs() < 0.5);
assert!(second.strokes[1].is_image());
}
#[test]
fn test_infinite_documents_become_endless() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = write_rnote(source_dir.path(), &sample("infinite"));
let (notebook, manifest, _, _) =
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
assert_eq!(manifest.layout, Some(NotebookLayout::Endless));
assert_eq!(notebook.pages.len(), 1);
assert!(notebook.pages[0].is_endless());
assert_eq!(notebook.pages[0].strokes.len(), 6);
let pen = &notebook.pages[0].strokes[1];
let home = crate::document::ENDLESS_HOME;
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
}
}
+4
View File
@@ -227,6 +227,8 @@ pub struct UiSettings {
pub stylus_buttons: Vec<StylusButton>,
/// Sprache der Oberfläche; ohne Angabe die des Systems
pub language: Option<Language>,
/// Eraser radius in mm (Issue #38)
pub eraser_radius_mm: f32,
}
impl Default for UiSettings {
@@ -246,6 +248,7 @@ impl Default for UiSettings {
active_text_color: 0,
stylus_buttons: Vec::new(),
language: None,
eraser_radius_mm: 3.0,
}
}
}
@@ -345,6 +348,7 @@ mod tests {
},
],
language: Some(Language::English),
eraser_radius_mm: 6.0,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
+1 -1
View File
@@ -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);
+374 -48
View File
@@ -110,6 +110,11 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
/// Eraser size presets as radius in mm, small to large (Issue #38).
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
/// Range of the eraser size slider (diameter in mm).
pub const ERASER_DIAMETER_RANGE_MM: (f32, f32) = (2.0, 30.0);
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -247,6 +252,8 @@ pub enum UiAction {
GoToPage(usize),
/// Alle noch nicht geladenen Seiten laden (für die Vorschaubilder der Seitenauswahl)
LoadAllPages,
/// Render these PDF pages small for the page picker (see `UiState::pdf_thumbnails`)
RequestPdfThumbnails(Vec<crate::document::PdfBackground>),
SetBackground(PageBackground),
ExportPdf,
ZoomIn,
@@ -331,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)
@@ -345,6 +352,16 @@ pub enum UiAction {
ForceExit,
}
/// Texture of a page picker thumbnail (egui's handle has no `Debug`).
#[derive(Clone)]
pub struct Thumbnail(pub egui::TextureHandle);
impl std::fmt::Debug for Thumbnail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Thumbnail({:?})", self.0.id())
}
}
/// Zustand der grafischen Benutzeroberfläche.
#[derive(Debug, Clone)]
pub struct UiState {
@@ -361,6 +378,8 @@ pub struct UiState {
pub toolbar_rect: Option<egui::Rect>,
/// Öffnungszustand des Einstellungs-Popovers für den Stift
pub is_pen_menu_open: bool,
/// Eraser size menu next to the size button (Issue #38)
pub is_eraser_menu_open: bool,
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
pub is_highlighter_menu_open: bool,
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
@@ -433,6 +452,11 @@ pub struct UiState {
pub is_more_menu_open: bool,
/// Welche Striche der Radierer entfernt (gilt auch für Stiftrückseite und Seitentaste)
pub eraser_mode: EraserMode,
/// Eraser radius in mm, one of [`ERASER_RADII_MM`] (also for the pen's eraser end)
pub eraser_radius_mm: f32,
/// Small renders of PDF pages for the page picker by [`crate::document::PdfBackground::key`];
/// `None` if the page could not be rendered (Issue #40)
pub pdf_thumbnails: std::collections::HashMap<String, Option<Thumbnail>>,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen
@@ -494,6 +518,7 @@ impl Default for UiState {
current_zoom: 1.0,
toolbar_rect: None,
is_pen_menu_open: false,
is_eraser_menu_open: false,
is_highlighter_menu_open: false,
popover_rect: None,
is_document_menu_open: false,
@@ -537,6 +562,8 @@ impl Default for UiState {
language: None,
is_more_menu_open: false,
eraser_mode: EraserMode::All,
eraser_radius_mm: ERASER_RADII_MM[1],
pdf_thumbnails: std::collections::HashMap::new(),
paper_theme: PaperTheme::White,
adapt_ink_colors: true,
pattern_color: PatternColor::Light,
@@ -573,6 +600,21 @@ impl UiState {
/// Abstand der Werkzeugleiste zum Fensterrand.
pub const SIDEBAR_MARGIN: f32 = 10.0;
/// Sets the eraser radius within the slider range (e.g. from the settings file).
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
let (min, max) = ERASER_DIAMETER_RANGE_MM;
self.eraser_radius_mm = if mm.is_finite() {
mm.clamp(min / 2.0, max / 2.0)
} else {
ERASER_RADII_MM[1]
};
}
/// Eraser radius in page units.
pub fn eraser_radius(&self) -> f32 {
self.eraser_radius_mm * crate::document::UNITS_PER_MM
}
/// Schließt alle Popover und Dialoge. Wird vor dem Öffnen eines neuen aufgerufen, damit
/// sich nie mehrere überlagern.
pub fn close_overlays(&mut self) {
@@ -580,6 +622,7 @@ impl UiState {
self.context_menu = None;
self.is_text_color_menu_open = false;
self.is_pen_menu_open = false;
self.is_eraser_menu_open = false;
self.is_highlighter_menu_open = false;
self.is_more_menu_open = false;
self.show_page_jump_popup = false;
@@ -626,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) {
@@ -920,6 +963,61 @@ fn stroke_preview_button(
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
}
/// Eraser size button of the eraser menu: a circle in proportion to the size and its diameter.
fn eraser_size_button(ui: &mut egui::Ui, is_active: bool, radius_mm: f32) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(egui::vec2(66.0, 40.0), egui::Sense::click());
let label = format!("{} mm", format_mm(radius_mm * 2.0));
if ui.is_rect_visible(rect) {
let painter = ui.painter();
let (bg, border) = if is_active {
(
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
egui::Color32::from_rgba_unmultiplied(120, 175, 255, 200),
)
} else if response.hovered() {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25),
)
} else {
(
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 14),
)
};
painter.rect_filled(rect, egui::Rounding::same(6.0), bg);
painter.rect_stroke(
rect,
egui::Rounding::same(6.0),
egui::Stroke::new(1.0_f32, border),
);
let text_color = if is_active {
egui::Color32::WHITE
} else {
egui::Color32::from_gray(190)
};
painter.circle_stroke(
egui::pos2(rect.center().x, rect.center().y - 5.0),
(radius_mm * 0.9).clamp(1.5, 11.0),
egui::Stroke::new(1.5_f32, text_color),
);
painter.text(
egui::pos2(rect.center().x, rect.max.y - 7.5),
egui::Align2::CENTER_CENTER,
&label,
egui::FontId::proportional(10.0),
text_color,
);
}
response.on_hover_text(t!("Radiergröße {size} setzen", size = label))
}
/// Millimeters without trailing zeros: 3, 1.5, 2.25.
fn format_mm(mm: f32) -> String {
let text = format!("{:.2}", mm);
text.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
#[derive(Clone, Copy)]
enum SidebarIcon<'a> {
@@ -933,6 +1031,8 @@ enum SidebarIcon<'a> {
Lasso,
/// Selbst gezeichnetes Lineal mit Skala
Ruler,
/// Filled circle with this radius in points (eraser size)
Dot(f32),
}
/// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante.
@@ -1035,6 +1135,9 @@ fn sidebar_button(
stroke,
));
}
SidebarIcon::Dot(radius) => {
painter.circle_filled(c, radius, color);
}
SidebarIcon::Ruler => {
let dir = egui::vec2(1.0, -1.0).normalized();
let perp = egui::vec2(dir.y, -dir.x);
@@ -1330,27 +1433,53 @@ fn color_circle_swatch(
/// Spalten der Seitenauswahl.
const PAGE_PICKER_COLUMNS: usize = 4;
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0);
/// Breite einer Kachel der Seitenauswahl; darunter kommt eine Zeile für die Seitenzahl.
const PAGE_TILE_WIDTH: f32 = 66.0;
const PAGE_TILE_LABEL: f32 = 18.0;
/// Size of a page picker tile: as high as the tallest page of the notebook needs, so that
/// slides (landscape PDFs) get flat tiles without an empty strip (Issue #40).
fn page_tile_size(notebook: &Notebook) -> egui::Vec2 {
let aspect = notebook
.pages
.iter()
.map(|p| p.size.y / p.size.x.max(1.0))
.fold(0.0_f32, f32::max);
let aspect = if aspect > 0.0 {
aspect.clamp(0.3, 2.0)
} else {
PAGE_HEIGHT / PAGE_WIDTH
};
egui::vec2(PAGE_TILE_WIDTH, PAGE_TILE_WIDTH * aspect + PAGE_TILE_LABEL)
}
/// Rectangle of a page of `page_size` in a thumbnail area: as large as possible, centered
/// horizontally and at the top.
fn thumbnail_sheet(page_size: glam::Vec2, rect: egui::Rect) -> egui::Rect {
let scale = (rect.width() / page_size.x).min(rect.height() / page_size.y);
let size = egui::vec2(page_size.x, page_size.y) * scale;
egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
)
}
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
const THUMBNAIL_POINT_BUDGET: usize = 1500;
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` in der Papierfarbe
/// `paper`: so groß wie möglich, waagerecht zentriert und oben (Querformat-Seiten sind
/// flacher). Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig
/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf. `pdf_texture` is the
/// rendered PDF page under the ink; without it the PDF page is a plain gray area.
pub fn page_thumbnail_shapes(
page: &Page,
rect: egui::Rect,
paper: PaperTheme,
adapt_ink: bool,
pdf_texture: Option<egui::TextureId>,
) -> Vec<egui::Shape> {
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
let size = egui::vec2(page.size.x, page.size.y) * scale;
let sheet = egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
);
let sheet = thumbnail_sheet(page.size, rect);
let scale = sheet.width() / page.size.x;
let mut shapes = vec![egui::Shape::rect_filled(
sheet,
egui::Rounding::same(3.0),
@@ -1359,18 +1488,27 @@ pub fn page_thumbnail_shapes(
let adapt = adapt_ink && paper.is_dark();
let to_screen = |p: glam::Vec2| sheet.min + egui::vec2(p.x * scale, p.y * scale);
// Importierte PDF-Seite als helle Fläche mit Rand
// Importierte PDF-Seite: gerendert oder als helle Fläche mit Rand
if let Some(pdf) = &page.pdf_background {
let points: Vec<egui::Pos2> = pdf
.corners(page.size)
.iter()
.map(|&c| to_screen(c))
.collect();
shapes.push(egui::Shape::convex_polygon(
points,
egui::Color32::from_rgb(236, 238, 243),
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
));
if let Some(texture) = pdf_texture {
shapes.push(egui::Shape::image(
texture,
egui::Rect::from_two_pos(points[0], points[2]),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
));
} else {
shapes.push(egui::Shape::convex_polygon(
points,
egui::Color32::from_rgb(236, 238, 243),
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
));
}
}
// Bilder als Flächen unter der Tinte
@@ -1564,30 +1702,44 @@ fn linear_color32(color: [f32; 4]) -> egui::Color32 {
egui::Rgba::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]).into()
}
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter.
/// Kachel der Seitenauswahl: Vorschau der Seite mit Seitenzahl darunter. A visible PDF page
/// that has not been rendered small yet is added to `wanted_pdfs`.
fn page_thumbnail(
ui: &mut egui::Ui,
state: &UiState,
page: &Page,
number: usize,
is_current: bool,
tile: egui::Vec2,
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
) -> egui::Response {
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
let (rect, response) = ui.allocate_exact_size(tile, egui::Sense::click());
if ui.is_rect_visible(rect) {
let thumb = egui::Rect::from_min_size(
rect.min,
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
);
let thumb =
egui::Rect::from_min_size(rect.min, egui::vec2(tile.x, tile.y - PAGE_TILE_LABEL));
let pdf_texture = match &page.pdf_background {
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
Some(texture) => texture.as_ref().map(|t| t.0.id()),
None => {
wanted_pdfs.push(pdf.clone());
None
}
},
None => None,
};
let painter = ui.painter();
painter.extend(page_thumbnail_shapes(
page,
thumb,
state.paper_theme,
state.adapt_ink_colors,
pdf_texture,
));
// Frame and marks follow the page itself, not the whole tile
let sheet = thumbnail_sheet(page.size, thumb);
if !page.is_loaded {
painter.text(
thumb.center(),
sheet.center(),
egui::Align2::CENTER_CENTER,
"…",
egui::FontId::proportional(16.0),
@@ -1596,7 +1748,7 @@ fn page_thumbnail(
}
// Lesezeichen-Ecke für benannte Seiten
if page.title.is_some() {
let corner = thumb.right_top();
let corner = sheet.right_top();
painter.add(egui::Shape::convex_polygon(
vec![
corner + egui::vec2(-14.0, 0.0),
@@ -1614,7 +1766,7 @@ fn page_thumbnail(
} else {
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
};
painter.rect_stroke(thumb, egui::Rounding::same(3.0), border);
painter.rect_stroke(sheet, egui::Rounding::same(3.0), border);
painter.text(
egui::pos2(rect.center().x, rect.max.y - 8.0),
egui::Align2::CENTER_CENTER,
@@ -2675,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."
),
),
};
@@ -2772,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;
}
@@ -2957,6 +3111,7 @@ pub fn render_ui(
context_menu(ctx, state, &mut actions);
let mut pen_btn_rect = None;
let mut eraser_btn_rect = None;
let mut high_btn_rect = None;
let mut text_color_btn_rect = None;
let mut more_btn_rect = None;
@@ -3144,7 +3299,7 @@ pub fn render_ui(
(
ActiveTool::Eraser,
SidebarIcon::Eraser,
t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich oder nur Textmarker"),
t!("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe"),
),
(
ActiveTool::Select,
@@ -3319,7 +3474,8 @@ pub fn render_ui(
}
let spacing = ui.spacing().item_spacing.y;
let mut used = shown as f32 * (swatch + spacing);
// Radierer: statt Farben die Radier-Modi im selben Platz
// Eraser: its modes and size take the place of the colors (swatch-sized, so that
// all four fit)
if state.active_tool == ActiveTool::Eraser {
for (mode, icon, indicator, tooltip) in [
(
@@ -3343,7 +3499,7 @@ pub fn render_ui(
] {
if sidebar_button(
ui,
btn,
swatch,
icon,
state.eraser_mode == mode,
true,
@@ -3354,8 +3510,31 @@ pub fn render_ui(
{
state.eraser_mode = mode;
}
used += btn + spacing;
used += swatch + spacing;
}
// Eraser size: opens a menu like the pens (Issue #38)
let diameter = format_mm(state.eraser_radius_mm * 2.0);
let tooltip = t!(
"Radiergröße {size} mm – tippen zum Ändern",
size = diameter
);
let dot = (state.eraser_radius_mm * 0.7).clamp(2.0, swatch * 0.45);
let response = sidebar_button(
ui,
swatch,
SidebarIcon::Dot(dot),
state.is_eraser_menu_open,
true,
None,
&tooltip,
);
eraser_btn_rect = Some(response.rect);
if response.clicked() {
let open = !state.is_eraser_menu_open;
state.close_overlays();
state.is_eraser_menu_open = open;
}
used += swatch + spacing;
}
// Auswahl: unter den 4 Stiftfarben ist Platz für das Schloss des
// Seitenverhältnisses beim Skalieren (Bilder: gesperrt, Issue #29)
@@ -3675,6 +3854,84 @@ pub fn render_ui(
frame.response.rect
});
popover_rect = Some(pop_response.inner);
}
} else if state.is_eraser_menu_open && state.active_tool == ActiveTool::Eraser {
if let Some(btn_rect) = eraser_btn_rect {
let (popover_pos, pivot) =
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
let pop_response = egui::Area::new(egui::Id::new("eraser_settings_popover"))
.fixed_pos(popover_pos)
.pivot(pivot)
.constrain(true)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let frame = popover_frame().show(ui, |ui| {
ui.set_width(320.0);
touch_friendly_spacing(ui);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(t!("Radierer"))
.strong()
.color(egui::Color32::WHITE)
.size(12.5),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if close_button(ui).clicked() {
state.is_eraser_menu_open = false;
}
},
);
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(t!("GRÖSSE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(3.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
for radius in ERASER_RADII_MM {
let is_sel = (state.eraser_radius_mm - radius).abs() < 0.05;
if eraser_size_button(ui, is_sel, radius).clicked() {
state.eraser_radius_mm = radius;
}
}
});
ui.add_space(10.0);
ui.label(
egui::RichText::new(t!("STUFENLOSE GRÖSSE"))
.size(10.5)
.strong()
.color(egui::Color32::from_gray(160)),
);
ui.add_space(2.0);
let mut diameter = state.eraser_radius_mm * 2.0;
if ui
.add(
egui::Slider::new(
&mut diameter,
ERASER_DIAMETER_RANGE_MM.0..=ERASER_DIAMETER_RANGE_MM.1,
)
.suffix(" mm")
.step_by(0.5),
)
.changed()
{
state.set_eraser_radius_mm(diameter / 2.0);
}
});
frame.response.rect
});
popover_rect = Some(pop_response.inner);
}
} else if state.is_highlighter_menu_open {
@@ -3940,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"));
@@ -4194,7 +4451,9 @@ pub fn render_ui(
popover_frame()
.show(ui, |ui| {
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
ui.set_width(PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE.x + 8.0) - 8.0);
ui.set_width(
PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE_WIDTH + 8.0) - 8.0,
);
touch_friendly_spacing(ui);
let title = t!("Seiten ({count})", count = notebook.pages.len());
if dialog_header(ui, &title, true) {
@@ -4268,6 +4527,8 @@ pub fn render_ui(
egui::ScrollArea::vertical()
.max_height(420.0)
.show(ui, |ui| {
let mut wanted_pdfs = Vec::new();
let tile = page_tile_size(notebook);
egui::Grid::new("page_picker_grid")
.spacing([8.0, 8.0])
.show(ui, |ui| {
@@ -4281,6 +4542,8 @@ pub fn render_ui(
page,
idx + 1,
is_current,
tile,
&mut wanted_pdfs,
)
.clicked()
{
@@ -4292,6 +4555,10 @@ pub fn render_ui(
}
}
});
if !wanted_pdfs.is_empty() {
actions
.push(UiAction::RequestPdfThumbnails(wanted_pdfs));
}
});
}
ui.add_space(4.0);
@@ -4321,6 +4588,7 @@ pub fn render_ui(
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
if (state.is_pen_menu_open
|| state.is_eraser_menu_open
|| state.is_highlighter_menu_open
|| state.is_text_color_menu_open
|| state.is_more_menu_open
@@ -4332,6 +4600,7 @@ pub fn render_ui(
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
if !in_toolbar && !in_popover {
state.is_pen_menu_open = false;
state.is_eraser_menu_open = false;
state.is_highlighter_menu_open = false;
state.is_text_color_menu_open = false;
state.is_more_menu_open = false;
@@ -5710,6 +5979,23 @@ mod tests {
);
}
#[test]
fn test_eraser_size_stays_in_the_slider_range() {
let mut state = UiState::new();
assert_eq!(state.eraser_radius(), 30.0);
// Values from the settings file stay within the slider range
state.set_eraser_radius_mm(7.0);
assert_eq!(state.eraser_radius_mm, 7.0);
state.set_eraser_radius_mm(100.0);
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.1 / 2.0);
state.set_eraser_radius_mm(0.0);
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.0 / 2.0);
state.set_eraser_radius_mm(f32::NAN);
assert_eq!(state.eraser_radius_mm, 3.0);
assert_eq!(format_mm(3.0), "3");
assert_eq!(format_mm(2.5), "2.5");
}
#[test]
fn test_view_mode_keeps_only_harmless_keys() {
use winit::keyboard::{Key, NamedKey, SmolStr};
@@ -5849,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")),
];
@@ -6314,13 +6600,13 @@ mod tests {
// Leere Seite: nur das weiße Blatt
let empty = Page::new(1, PageBackground::Blank);
assert_eq!(
page_thumbnail_shapes(&empty, rect, PaperTheme::White, true).len(),
page_thumbnail_shapes(&empty, rect, PaperTheme::White, true, None).len(),
1
);
// Striche liegen innerhalb der Kachel
let page = page_with_strokes(3, 20);
let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true);
let shapes = page_thumbnail_shapes(&page, rect, PaperTheme::White, true, None);
assert_eq!(shapes.len(), 1 + 3);
let pts = shape_points(&shapes);
assert!(!pts.is_empty());
@@ -6331,7 +6617,13 @@ mod tests {
// Sehr volle Seite wird auf das Punktbudget ausgedünnt (+ Endpunkte je Strich)
let full = page_with_strokes(100, 1000);
let pts = shape_points(&page_thumbnail_shapes(&full, rect, PaperTheme::White, true));
let pts = shape_points(&page_thumbnail_shapes(
&full,
rect,
PaperTheme::White,
true,
None,
));
assert!(
pts.len() <= THUMBNAIL_POINT_BUDGET + 2 * 100,
"{} Punkte statt höchstens {}",
@@ -6354,12 +6646,46 @@ mod tests {
],
),
);
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true);
let shapes = page_thumbnail_shapes(&with_image, rect, PaperTheme::White, true, None);
assert_eq!(shapes.len(), 3);
assert!(
matches!(shapes[1], egui::Shape::Path(ref p) if p.closed && p.fill != egui::Color32::TRANSPARENT)
);
assert!(matches!(shapes[2], egui::Shape::Path(ref p) if !p.closed));
// PDF page: the rendered page fills the PDF area, otherwise a gray area (Issue #40)
let mut with_pdf = Page::new(2, PageBackground::Blank);
with_pdf.pdf_background = Some(crate::document::PdfBackground {
asset: "a.pdf".into(),
page: 0,
width_pt: 595.0,
height_pt: 842.0,
});
let gray = page_thumbnail_shapes(&with_pdf, rect, PaperTheme::White, true, None);
assert!(matches!(gray[1], egui::Shape::Path(ref p) if p.closed));
let texture = egui::TextureId::User(7);
let shapes = page_thumbnail_shapes(&with_pdf, rect, PaperTheme::White, true, Some(texture));
assert!(matches!(shapes[1], egui::Shape::Mesh(ref m) if m.texture_id == texture));
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
}
#[test]
fn test_page_tiles_follow_the_page_shape() {
// Slides only: flat tiles without an empty strip below the page (Issue #40)
let mut notebook = Notebook::new();
notebook.pages[0].size = glam::Vec2::new(2970.0, 1671.0);
let flat = page_tile_size(&notebook);
assert!((flat.y - PAGE_TILE_LABEL - flat.x * 1671.0 / 2970.0).abs() < 0.01);
let thumb =
egui::Rect::from_min_size(egui::Pos2::ZERO, flat - egui::vec2(0.0, PAGE_TILE_LABEL));
let sheet = thumbnail_sheet(notebook.pages[0].size, thumb);
assert!((sheet.height() - thumb.height()).abs() < 0.01);
// A portrait page among them: tiles as high as A4, slides at the top
let index = notebook.add_page(PageBackground::Blank);
notebook.pages[index].size = crate::document::A4_SIZE;
let tall = page_tile_size(&notebook);
assert!((tall.y - PAGE_TILE_LABEL - tall.x * PAGE_HEIGHT / PAGE_WIDTH).abs() < 0.01);
}
#[test]
+27
View File
@@ -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
View File
@@ -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) = &current_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 &amp; 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());
}
}
+7
View File
@@ -11,6 +11,13 @@ docs of the original crate were removed.
are now passed to the frame like a left click: close, minimize and maximize work with a
finger, and dragging the title bar moves the window (Issue #36). All changes are marked with
`ink-notes patch`.
- `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.
+50
View File
@@ -8,6 +8,56 @@ use crate::platform_impl::{ApplicationName, Backend};
pub use crate::window::Theme;
/// ink-notes patch: touches on the client-side decorations that start before this instant
/// are ignored (palm rejection while a pen is near the screen); `None` accepts them.
static IGNORE_DECORATION_TOUCHES_UNTIL: std::sync::Mutex<Option<std::time::Instant>> =
std::sync::Mutex::new(None);
/// ink-notes patch: ignore touches on the client-side decorations until `until`.
pub fn ignore_decoration_touches_until(until: Option<std::time::Instant>) {
*IGNORE_DECORATION_TOUCHES_UNTIL
.lock()
.unwrap_or_else(|e| e.into_inner()) = until;
}
/// ink-notes patch: whether a touch on the decorations is ignored right now.
pub(crate) fn decoration_touches_ignored() -> bool {
IGNORE_DECORATION_TOUCHES_UNTIL
.lock()
.unwrap_or_else(|e| e.into_inner())
.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.
@@ -35,6 +35,10 @@ impl TouchHandler for WinitState {
.data::<SurfaceData>()
.and_then(|data| data.parent_surface().cloned());
if let Some(parent_surface) = parent_surface.filter(|parent| *parent != surface) {
// A palm resting on the title bar while writing must not move or close the window
if crate::platform::wayland::decoration_touches_ignored() {
return;
}
let window_id = wayland::make_wid(&parent_surface);
let mut window = match self.windows.get_mut().get_mut(&window_id) {
Some(window) => window.lock().unwrap(),
@@ -79,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(
@@ -134,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(
@@ -153,7 +162,7 @@ impl TouchHandler for WinitState {
_: &Connection,
_: &QueueHandle<Self>,
touch: &WlTouch,
_: u32,
time: u32,
id: i32,
position: (f64, f64),
) {
@@ -188,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(