Compare commits

..
21 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 9a39276da4 chore(release): bump version to 0.5.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m17s
Bilder einfügen (Zwischenablage, Dateiauswahl, Drag & Drop), Dateiformat-Version 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:04:09 +02:00
haraldandClaude Opus 5.5 b5c1da07fa feat: Bilder einfügen (Datei, Zwischenablage, Drag & Drop)
- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl),
  Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster
- Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren,
  kopieren, löschen und Undo über die bestehende Auswahl
- Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets
  (JPEG unverändert, sonst PNG); Dateiformat-Version 4
- GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben
  lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang
- PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi
- Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte);
  Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:50:54 +02:00
haraldandClaude Opus 5.5 c52e958c7f chore(release): bump version to 0.4.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m12s
Dunkelmodus & Papierfarben, Textmarker-Radierer, einstellbare Druckkurve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:43 +02:00
haraldandClaude Opus 5.5 3e2b627d6a feat: Textmarker-Radierer, Papierfarben/Dunkelmodus und Druckkurve
- Radierer: Modus „Nur Textmarker“ (gilt auch für Stiftrückseite und Seitentaste)
- Striche speichern jetzt ihr Werkzeug; bisher wurde jeder Strich als "pen"
  gespeichert. Ältere Textmarker werden an der Halbtransparenz erkannt.
- Papierfarben Weiß/Creme/Dunkelgrau/Schwarz (nur Anzeige); auf dunklem Papier
  hellt der Shader dunkle Tinte auf (HSL-Helligkeit gespiegelt)
- Einstellbare Druckkurve (weich/hart, Mindestdruck) mit Vorschau
- Einstellungen in ui.json; ☰-Menü scrollt bei kleinen Fenstern
- Test prüft den WGSL-Shader mit naga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:04:59 +02:00
haraldandClaude Opus 5.5 616fc57666 chore(release): bump version to 0.3.7
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m48s
Ellipsen- und Pfeilerkennung, spitze Pfeilspitzen, Dateiformat-Version 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:42:24 +02:00
haraldandClaude Opus 5.5 d150fd1883 feat(geometry): Ellipsen- und Pfeilerkennung
Ellipse
- Achsen aus der Hauptkomponentenanalyse des gleichmäßig abgetasteten Strichs,
  Halbachsen aus der Ausdehnung (2.–98. Perzentil); fast runde Formen
  (Achsenverhältnis ≥ 0,85) bleiben Kreise; leicht schiefe werden gerade
- konkurriert im Formvergleich mit Kreis, Rechteck, Dreieck, Raute, Vieleck

Pfeil / Doppelpfeil
- in einem Zug gezeichnet: langer Schaft, an einem oder beiden Enden
  Widerhaken auf beiden Seiten (10°–80° schräg nach hinten); T-Enden und
  Zickzack sind keine Pfeile
- Schaft wird über fast gerade weiterlaufende Punkte verlängert (die
  Vereinfachung teilt ihn sonst kurz vor der Spitze)
- Spitzen über die gemeinsame arrow_head-Geometrie, Schaft endet an der Basis

Außerdem: Drehen/Skalieren/Verschieben, PDF-Export, Namen für die
Einrast-Meldung; Dateiformat-Version 3, damit ältere App-Versionen das
Notizbuch sauber ablehnen statt Seiten mit neuen Formen für beschädigt zu
halten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:28:17 +02:00
haraldandClaude Opus 5.5 f9ede537ed fix(geometry): spitze Pfeilspitzen beim Koordinatensystem
Die Achsenlinien liefen in voller Strichbreite bis zur Spitze und überdeckten
sie; zudem war die Spitze klein und breit (~67° Öffnung).

- Gemeinsame Pfeilspitzen-Geometrie (arrow_head) für Bildschirm und PDF:
  ~4 mm lang (dicke Linien länger, höchstens 1/5 der Achse), ~40° Öffnung
- Achsen enden an der Basis der Spitze
- PDF-Export nutzt dieselben Maße (vorher eigene, abweichende Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:14:06 +02:00
haraldandClaude Opus 5.5 9571e2121c chore(release): bump version to 0.3.6
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m51s
Unter WSL standardmäßig X11 statt Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:50:24 +02:00
haraldandClaude Opus 5.5 4a7627e461 fix(wsl): unter WSL standardmäßig X11 statt Wayland verwenden
Unter WSLg trennt der Wayland-Compositor die Verbindung, kurz nachdem die App
ein Bild über Vulkan übergibt ("Connection reset by peer", ExitFailure). Über
X11 (Xwayland) läuft die App stabil – deshalb funktionierte sie als root, wo
der Wayland-Socket nicht gefunden wird und winit auf X11 ausweicht.

- WSL-Erkennung (WSL_DISTRO_NAME/WSL_INTEROP, /proc/sys/kernel/osrelease)
- Unter WSL mit gesetztem DISPLAY wird die Ereignisschleife mit X11 erzeugt
- INK_NOTES_DISPLAY=x11|wayland übersteuert die Wahl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:21 +02:00
haraldandClaude Opus 5.5 cb11b1f828 chore(release): bump version to 0.3.5
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m34s
Kein Absturz mehr ohne nutzbare Grafikkarte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:49 +02:00
haraldandClaude Opus 5.5 89005f718e fix(grafik): kein Absturz ohne nutzbare Darstellungsmodi
Ohne Zugriff auf die Grafikkarte (z. B. Benutzer nicht in der Gruppe render)
lieferte EGL/Zink zunächst Darstellungsmodi, später eine leere Liste; die
nächste Neueinrichtung der Surface ließ wgpu abstürzen ("Requested present
mode Mailbox is not in the list of supported present modes: []").

- Adapter der Reihe nach prüfen (GPU, dann Software-Renderer); nur Adapter
  mit Formaten und Darstellungsmodi für das Fenster werden genommen
- Darstellungsmodus: Mailbox, sonst Fifo, sonst der erste angebotene
- Bei Größenänderung/verlorener Surface Fähigkeiten neu abfragen statt blind
  neu einzurichten; ohne Modi wird nicht eingerichtet (einmalige Meldung)
- Ohne nutzbaren Adapter verständliche Meldung (Gruppe render, /dev/dri,
  Software-Renderer, WGPU_BACKEND) statt Panic; WGPU_BACKEND wird beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:14:43 +02:00
haraldandClaude Opus 5.5 b5113cefa2 chore(release): bump version to 0.3.4
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m14s
Formerkennung: Rechtecke statt Kreise, Freiformen bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:21:00 +02:00
haraldandClaude Opus 5.5 88ea7d2d53 fix(geometry): Freiformen bleiben unverändert, Vielecke werden erkannt
- Eine erkannte Form ersetzt den Strich nur noch, wenn sie ihn gut beschreibt
  (mittlere Abweichung ≤ 4 % der Strichdiagonale, gemessen gegen den
  Linienzug der Form). Vorher wurde fast jeder geschlossene Strich umgewandelt
  (Herz → Kreis, Stern/Spirale → Oktaeder, Wolke → Dreieck).
- Vielecke verlangen echte Ecken: Die Richtungsänderung muss sich an den Ecken
  konzentrieren; glatte Ovale werden so nicht mehr zu Sechs-/Achtecken.
- Vielecke konkurrieren mit Kreis, Dreieck, Rechteck und Raute (gewichtet:
  Rechteck vor Raute/Vieleck, Vieleck vor Kreis); überzählige Punkte der
  Vereinfachung (Strichende vor dem Start, Punkte auf einer Geraden) entfallen.
- Tests: Freiformen bleiben unverändert, Fünf-/Sechs-/Achtecke, Ovale;
  jeder Einzeldetektor-Test prüft zusätzlich die vollständige Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:19:57 +02:00
haraldandClaude Opus 5.5 7345c00568 fix(geometry): Rechtecke und Quadrate werden nicht mehr als Kreis erkannt
- Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird die Form,
  deren Kontur am besten zu den Punkten passt (RMS-Abweichung). Vorher gewann
  der Kreis immer, weil auch Quadrate nur ~10 % Radiusschwankung haben, und
  die Dreieckserkennung griff gestreckte Rechtecke ab.
- Rechteckerkennung über das kleinste umschließende Rechteck (alle Drehwinkel,
  Ausreißer-robust), Kanten auf den Median der Punkte; verlangt gezeichnete
  Ecken und enge Passung, damit Ovale und Kritzeleien keine Rechtecke werden.
  Die alte Variante schnitt bei kleinen/zittrigen Strichen die Ecken ab.
- extract_k_corners wählte die geradesten statt der markantesten Punkte
  (Sortierung verkehrt herum); betrifft auch Dreieck und Raute.
- Rechteck wird der Raute vorgezogen, wenn es fast so gut passt.
- Oktaeder nur noch, wenn keine geschlossene Form gut passt.
- Tests mit nachgebildeten Freihandstrichen: Rechtecke in vielen Größen,
  Seitenverhältnissen und Drehungen, mit runden Ecken, schiefen Kanten und
  Überstrich; Kreise, Dreiecke; Ellipsen und Freiformen nie als Rechteck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:05:37 +02:00
haraldandClaude Opus 5.5 f76fad92be chore(release): bump version to 0.3.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m10s
Behebt Datenverlust nach Neustart auf einer späteren Seite (v0.2.3 – v0.3.2); Seitenauswahl mit Vorschaubildern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:58 +02:00
haraldandClaude Opus 5.5 2c60a03460 feat(ui): Vorschaubilder in der Seitenauswahl
- Kacheln zeigen eine verkleinerte A4-Vorschau jeder Seite (Striche als
  Linien, Textmarker halbtransparent, volle Seiten auf ~1500 Punkte
  ausgedünnt), Seitenzahl darunter, aktuelle Seite blau umrandet
- Noch nicht geladene Seiten werden bei offener Seitenauswahl nachgeladen
  (UiAction::LoadAllPages)
- Kachelgröße über PAGE_TILE (vorher feste Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 b6580f197d fix(storage): Inhalt der zuletzt geöffneten Seite nach Neustart nicht überschreiben
Beim Start wurde nur die erste Seite geladen. War zuletzt eine spätere Seite
offen, war sie nach dem Neustart ein leerer, ungeladener Platzhalter: Das
Blatt erschien leer, und Zeichnen/Radieren/Hintergrund wechseln speicherte
den Platzhalter und überschrieb den bisherigen Inhalt der Seite.

- load_notebook lädt zusätzlich die aktuelle Seite
- Autosave schreibt nie ungeladene Platzhalter (Schutz ging beim Merge des
  Storage-Branches verloren)
- Regressionstests für beide Fälle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 22e35f2e77 chore(release): bump version to 0.3.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:40:44 +02:00
haraldandClaude Opus 5.5 a77e6cde90 feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover
- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:51:48 +02:00
haraldandClaude Opus 5.5 40eb9a666f chore(release): bump version to 0.3.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m58s
Behebt: In v0.3.0 ließ sich auf dem Blatt nicht zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:54 +02:00
haraldandClaude Opus 5.5 85d1ba1647 fix(ui): Zeichnen auf dem Blatt wieder möglich; Farben und Zeichen korrigiert
Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine
bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder
Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht.
Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs
und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2).

Außerdem:
- 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert
  umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift,
  die Auswahlfüllung war deckend statt durchscheinend
- Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen
  U+FE0F hinter ⚠)
- Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in
  ui.rs, selection.rs und main.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:12:56 +02:00
20 changed files with 5950 additions and 1074 deletions
Generated
+284 -6
View File
@@ -79,6 +79,26 @@ dependencies = [
"libc",
]
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"x11rb",
]
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -182,7 +202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68"
dependencies = [
"block-sys",
"objc2",
"objc2 0.4.1",
]
[[package]]
@@ -222,6 +242,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -290,6 +316,15 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -300,6 +335,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "com"
version = "0.6.0"
@@ -420,6 +461,12 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -469,6 +516,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -623,12 +680,33 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -642,7 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -770,6 +848,16 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -863,6 +951,17 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -929,7 +1028,7 @@ checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319"
dependencies = [
"block2",
"dispatch",
"objc2",
"objc2 0.4.1",
]
[[package]]
@@ -1036,6 +1135,35 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -1048,15 +1176,18 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.3.0"
version = "0.5.0"
dependencies = [
"arboard",
"bytemuck",
"egui",
"egui-wgpu",
"egui-winit",
"fs2",
"glam",
"image",
"minisign-verify",
"naga",
"octotablet",
"pollster",
"printpdf",
@@ -1311,6 +1442,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1321,6 +1462,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -1443,7 +1594,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d"
dependencies = [
"objc-sys",
"objc2-encode",
"objc2-encode 3.0.0",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode 4.1.0",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-graphics",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2 0.6.5",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
@@ -1452,6 +1648,34 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666"
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-foundation",
]
[[package]]
name = "objc_exception"
version = "0.1.2"
@@ -1567,6 +1791,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -1653,6 +1890,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -2141,6 +2390,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.55"
@@ -3063,7 +3326,7 @@ dependencies = [
"memmap2",
"ndk",
"ndk-sys",
"objc2",
"objc2 0.4.1",
"once_cell",
"orbclient",
"percent-encoding",
@@ -3286,3 +3549,18 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.3.0"
version = "0.5.0"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -39,6 +39,10 @@ sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5"
octotablet = "0.1.0"
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
arboard = { version = "3", default-features = false, features = ["image-data"] }
minisign-verify = "0.3.0"
fs2 = "0.4"
@@ -53,3 +57,5 @@ strip = true
[dev-dependencies]
tempfile = "3.27.0"
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
naga = { version = "0.19", features = ["wgsl-in"] }
+10 -1
View File
@@ -3,12 +3,21 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
+355
View File
@@ -0,0 +1,355 @@
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
//!
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
//! Kopien auf anderen Seiten immer funktionieren.
//!
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
//! werden als PNG gespeichert.
use std::io::Cursor;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::storage;
/// Name des Unterordners mit den Bilddateien.
pub const ASSETS_DIR: &str = "assets";
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
/// Größte Dateigröße beim Einfügen.
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
/// Fehler beim Einfügen oder Laden eines Bildes.
#[derive(Debug)]
pub enum AssetError {
/// Format nicht unterstützt oder Datei beschädigt
Decode(String),
/// Datei zu groß
TooLarge(usize),
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
InvalidName(String),
Io(std::io::Error),
}
impl std::fmt::Display for AssetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
AssetError::TooLarge(bytes) => write!(
f,
"Bild ist zu groß ({} MB, höchstens {} MB)",
bytes / (1024 * 1024),
MAX_IMPORT_BYTES / (1024 * 1024)
),
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
}
}
}
impl std::error::Error for AssetError {}
impl From<std::io::Error> for AssetError {
fn from(e: std::io::Error) -> Self {
AssetError::Io(e)
}
}
/// Ergebnis eines erfolgreichen Imports.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedImage {
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
pub asset: String,
pub width: u32,
pub height: u32,
}
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
#[derive(Debug, Clone, PartialEq)]
pub struct RgbaImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png` oder `.jpg`. Verhindert, dass
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
pub fn is_valid_asset_name(name: &str) -> bool {
let Some((hash, ext)) = name.split_once('.') else {
return false;
};
hash.len() == 64
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& matches!(ext, "png" | "jpg")
}
/// Bilddateien eines Notizbuchs.
#[derive(Debug, Clone)]
pub struct AssetStore {
dir: PathBuf,
}
impl AssetStore {
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
pub fn new(notebook_dir: &Path) -> Self {
Self {
dir: notebook_dir.join(ASSETS_DIR),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Pfad einer Bilddatei (nur für gültige Namen).
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
if is_valid_asset_name(asset) {
Ok(self.dir.join(asset))
} else {
Err(AssetError::InvalidName(asset.to_string()))
}
}
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
if bytes.len() > MAX_IMPORT_BYTES {
return Err(AssetError::TooLarge(bytes.len()));
}
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
let decoded = image::load_from_memory_with_format(bytes, format)
.map_err(|e| AssetError::Decode(e.to_string()))?;
let fits =
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
if format == image::ImageFormat::Jpeg && fits {
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
return self.store(bytes, "jpg", decoded.width(), decoded.height());
}
let decoded = if fits {
decoded
} else {
decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
)
};
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
pub fn import_rgba(
&self,
width: u32,
height: u32,
pixels: Vec<u8>,
) -> Result<ImportedImage, AssetError> {
let buffer = image::RgbaImage::from_raw(width, height, pixels)
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
decoded = decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
);
}
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
let target = self.path_of(asset)?;
if target.exists() || other.dir == self.dir {
return Ok(());
}
let bytes = std::fs::read(other.path_of(asset)?)?;
storage::atomic_write_file(&target, &bytes)?;
Ok(())
}
/// Liest die gespeicherte Bilddatei unverändert.
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
Ok(std::fs::read(self.path_of(asset)?)?)
}
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
/// `max_dimension` Pixel Kantenlänge.
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
let bytes = self.read_bytes(asset)?;
let mut decoded =
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
if decoded.width() > max_dimension || decoded.height() > max_dimension {
decoded = decoded.resize(
max_dimension,
max_dimension,
image::imageops::FilterType::Triangle,
);
}
let rgba = decoded.into_rgba8();
Ok(RgbaImage {
width: rgba.width(),
height: rgba.height(),
pixels: rgba.into_raw(),
})
}
fn store(
&self,
bytes: &[u8],
ext: &str,
width: u32,
height: u32,
) -> Result<ImportedImage, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.{ext}", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(ImportedImage {
asset,
width,
height,
})
}
}
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
let mut png = Vec::new();
image
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| AssetError::Decode(e.to_string()))?;
Ok(png)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
pub fn is_supported_image_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
)
})
.unwrap_or(false)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
pub(crate) fn sample_image_bytes(
width: u32,
height: u32,
format: image::ImageFormat,
) -> Vec<u8> {
let img = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([
(x * 255 / width.max(1)) as u8,
(y * 255 / height.max(1)) as u8,
90,
])
});
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut bytes), format)
.unwrap();
bytes
}
#[test]
fn test_asset_names_are_validated() {
let hash = "a".repeat(64);
assert!(is_valid_asset_name(&format!("{hash}.png")));
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
assert!(!is_valid_asset_name("../../etc/passwd"));
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
assert!(!is_valid_asset_name(""));
let store = AssetStore::new(Path::new("/tmp/nb"));
assert!(store.path_of("../manifest.json").is_err());
}
#[test]
fn test_import_png_jpeg_and_deduplicate() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(dir.path());
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
let a = store.import_bytes(&png).unwrap();
assert_eq!((a.width, a.height), (40, 20));
assert!(a.asset.ends_with(".png"));
assert!(store.path_of(&a.asset).unwrap().exists());
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
let b = store.import_bytes(&png).unwrap();
assert_eq!(a, b);
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
// JPEG bleibt byte-identisch
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
let c = store.import_bytes(&jpg).unwrap();
assert!(c.asset.ends_with(".jpg"));
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
// BMP wird als PNG gespeichert
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
let d = store.import_bytes(&bmp).unwrap();
assert!(d.asset.ends_with(".png"));
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
assert_eq!((rgba.width, rgba.height), (40, 20));
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
// Verkleinerung für die Anzeige behält das Seitenverhältnis
let small = store.load_rgba(&a.asset, 10).unwrap();
assert_eq!((small.width, small.height), (10, 5));
}
#[test]
fn test_import_rejects_garbage_and_copies_between_notebooks() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(&dir.path().join("a"));
assert!(matches!(
store.import_bytes(b"kein Bild"),
Err(AssetError::Decode(_))
));
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
let other = AssetStore::new(&dir.path().join("b"));
assert!(other.read_bytes(&imported.asset).is_err());
other.copy_from(&store, &imported.asset).unwrap();
assert_eq!(
other.read_bytes(&imported.asset).unwrap(),
store.read_bytes(&imported.asset).unwrap()
);
}
#[test]
fn test_supported_image_paths() {
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
assert!(is_supported_image_path(Path::new("a.webp")));
assert!(!is_supported_image_path(Path::new("a.pdf")));
assert!(!is_supported_image_path(Path::new("png")));
}
}
+6 -1
View File
@@ -312,7 +312,12 @@ impl AutosaveController {
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
// Nicht geladene Platzhalter nie schreiben: Sie sind leer und würden den Inhalt
// der Seitendatei überschreiben
if self.read_only
|| !page.is_loaded
|| page.persist == crate::document::PersistState::ReadOnlyAfterLoadError
{
return;
}
let page_data = PageData::from(page);
+195 -36
View File
@@ -1,5 +1,7 @@
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
@@ -13,43 +15,122 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0;
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
r: 0.16,
g: 0.16,
b: 0.18,
a: 1.0,
};
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
///
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaperTheme {
#[default]
White,
/// Cremefarben (augenschonend, wie Recyclingpapier)
Cream,
/// Dunkelgrau (Nachtmodus)
DarkGray,
/// Schwarz (OLED)
Black,
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
pub fn generate_paper_quad() -> Vec<InkVertex> {
impl PaperTheme {
pub const ALL: [PaperTheme; 4] = [
PaperTheme::White,
PaperTheme::Cream,
PaperTheme::DarkGray,
PaperTheme::Black,
];
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => "Weiß",
PaperTheme::Cream => "Creme",
PaperTheme::DarkGray => "Dunkelgrau",
PaperTheme::Black => "Schwarz",
}
}
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
pub fn is_dark(self) -> bool {
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
}
/// Farbe des Blatts.
pub fn paper_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
// sRGB #FBF5E6
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
// sRGB #2B2B2E
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
}
}
/// Farbe von Karos, Linien und Punkten.
pub fn pattern_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
// sRGB #48484D bzw. #38383C
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
}
}
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
/// das Blatt abhebt.
pub fn desk_color(self) -> wgpu::Color {
let (r, g, b) = match self {
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
wgpu::Color { r, g, b, a: 1.0 }
}
}
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
let max = color[0].max(color[1]).max(color[2]);
let min = color[0].min(color[1]).min(color[2]);
let lift = (1.0 - max - min).max(0.0);
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
vec![
InkVertex::new(0.0, 0.0, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
]
}
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline]
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
fn push_quad(
vertices: &mut Vec<InkVertex>,
color: [f32; 4],
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
) {
vertices.push(InkVertex::new(x_min, y_min, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_max, y_max, color));
}
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
match background {
PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => {
@@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
// Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
push_quad(
&mut vertices,
color,
x - half_t,
0.0,
x + half_t,
PAGE_HEIGHT,
);
x += GRID_5MM_SPACING;
}
// Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += GRID_5MM_SPACING;
}
@@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += LINED_SPACING;
}
@@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
while x < PAGE_WIDTH {
push_quad(
&mut vertices,
color,
x - half_d,
y - half_d,
x + half_d,
@@ -119,18 +222,20 @@ mod tests {
#[test]
fn test_paper_quad_generation() {
let quad = generate_paper_quad();
let quad = generate_paper_quad(PaperTheme::White.paper_color());
assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
}
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test]
fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank);
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm);
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
assert!(!grid.is_empty());
assert_eq!(
grid.len() % 6,
@@ -138,12 +243,66 @@ mod tests {
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
);
let lined = generate_background_mesh(PageBackground::Lined);
let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid);
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0);
}
#[test]
fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
}
#[test]
fn test_adapt_ink_color() {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
// Schwarz wird Weiß, Deckkraft bleibt
assert!(close(
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
[1.0, 1.0, 1.0, 0.7]
));
// Notiz-Schwarz wird hell
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
assert!(ink[0] > 0.85 && ink[2] > 0.85);
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
for c in [
[0.85, 0.20, 0.20, 1.0],
[0.15, 0.35, 0.85, 1.0],
[1.0, 0.95, 0.1, 0.35],
[1.0, 1.0, 1.0, 1.0],
] {
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
}
// Ergebnis ist nie heller als Weiß
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
}
}
#[test]
fn test_paper_themes_contrast() {
for theme in PaperTheme::ALL {
let paper = theme.paper_color()[0];
let pattern = theme.pattern_color()[0];
assert!(
(paper - pattern).abs() > 0.03,
"{theme:?}: Muster hebt sich nicht vom Papier ab"
);
// Helles Papier ↔ dunkles Papier
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
}
}
}
+200 -43
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize;
use winit::window::Window;
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig};
@@ -58,6 +58,8 @@ impl InkVertex {
pub struct CameraUniforms {
pub transform_scale: [f32; 2],
pub transform_offset: [f32; 2],
/// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung)
pub ink_params: [f32; 4],
}
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
@@ -87,8 +89,35 @@ pub fn spline_vertices_to_ndc(
}
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
let preferred: &[wgpu::PresentMode] = if force_fifo {
&[wgpu::PresentMode::Fifo]
} else {
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
};
preferred
.iter()
.copied()
.find(|m| available.contains(m))
.or_else(|| available.first().copied())
}
pub struct CanvasState {
pub surface: wgpu::Surface<'static>,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration,
@@ -108,7 +137,6 @@ pub struct CanvasState {
pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32,
pub current_tool: crate::document::StrokeTool,
// Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer,
@@ -116,6 +144,10 @@ pub struct CanvasState {
pub background_vertex_capacity: usize,
pub background_vertex_count: u32,
pub current_background: PageBackground,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen
pub adapt_ink: bool,
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
pub live_vertex_buffer: wgpu::Buffer,
@@ -133,6 +165,9 @@ pub struct CanvasState {
pub committed_vertex_count: u32,
pub committed_strokes: Vec<Range<u32>>,
// Eingefügte Bilder der aktuellen Seite
image_layer: crate::image_layer::ImageLayer,
// egui UI-Renderer
pub egui_renderer: egui_wgpu::Renderer,
}
@@ -149,23 +184,47 @@ impl CanvasState {
let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface
// 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
..Default::default()
});
let surface = instance.create_surface(window.clone())?;
// 2. Adapter auswählen (hohe Performance bevorzugt)
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.ok_or("Kein passender GPU-Adapter gefunden")?;
// 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
// Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
// anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
let mut chosen = None;
for force_fallback_adapter in [false, true] {
let Some(adapter) = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter,
})
.await
else {
continue;
};
let info = adapter.get_info();
let caps = surface.get_capabilities(&adapter);
match choose_present_mode(&caps.present_modes) {
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
eprintln!(
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
info.name, info.backend, mode
);
chosen = Some((adapter, caps, mode));
break;
}
_ => eprintln!(
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
info.name, info.backend
),
}
}
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
// 3. Device & Queue erstellen
let (device, queue) = adapter
@@ -179,8 +238,7 @@ impl CanvasState {
)
.await?;
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1
let surface_caps = surface.get_capabilities(&adapter);
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
let surface_format = surface_caps
.formats
.iter()
@@ -188,20 +246,6 @@ impl CanvasState {
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps
.present_modes
.contains(&wgpu::PresentMode::Mailbox)
{
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
wgpu::PresentMode::Fifo
};
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
@@ -240,6 +284,7 @@ impl CanvasState {
let camera_uniforms = CameraUniforms {
transform_scale: scale,
transform_offset: offset,
ink_params: [0.0; 4],
};
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Camera Uniform Buffer"),
@@ -276,7 +321,7 @@ impl CanvasState {
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs_main",
entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -330,7 +375,8 @@ impl CanvasState {
});
// 6. Statische Buffer
let paper_quad = generate_paper_quad();
let paper_theme = PaperTheme::default();
let paper_quad = generate_paper_quad(paper_theme.paper_color());
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Paper Quad Buffer"),
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
@@ -340,7 +386,7 @@ impl CanvasState {
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
let current_background = PageBackground::Grid5mm;
let bg_mesh = generate_background_mesh(current_background);
let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer"),
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
@@ -364,11 +410,20 @@ impl CanvasState {
mapped_at_creation: false,
});
let image_layer = crate::image_layer::ImageLayer::new(
&device,
&shader,
&bind_group_layout,
config.format,
);
// 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
Ok(Self {
surface,
adapter,
presentation_warned: false,
device,
queue,
config,
@@ -382,12 +437,13 @@ impl CanvasState {
camera_bind_group,
current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5,
current_tool: crate::document::StrokeTool::Pen,
paper_buffer,
background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
background_vertex_count: bg_mesh.len() as u32,
current_background,
paper_theme,
adapt_ink: false,
live_vertex_buffer,
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
live_vertex_count: 0,
@@ -404,6 +460,7 @@ impl CanvasState {
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128),
image_layer,
egui_renderer,
camera: crate::viewport::ViewportState::new(),
})
@@ -415,6 +472,16 @@ impl CanvasState {
let camera_uniforms = CameraUniforms {
transform_scale: scale,
transform_offset: offset,
ink_params: [
if self.adapt_ink && self.paper_theme.is_dark() {
1.0
} else {
0.0
},
0.0,
0.0,
0.0,
],
};
self.queue.write_buffer(
&self.camera_buffer,
@@ -452,6 +519,23 @@ impl CanvasState {
self.size = new_size;
self.config.width = new_size.width;
self.config.height = new_size.height;
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
let caps = self.surface.get_capabilities(&self.adapter);
if !caps.present_modes.contains(&self.config.present_mode) {
match choose_present_mode(&caps.present_modes) {
Some(mode) => self.config.present_mode = mode,
None => {
if !self.presentation_warned {
eprintln!(
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
NO_USABLE_GPU_MESSAGE
);
self.presentation_warned = true;
}
return;
}
}
}
self.surface.configure(&self.device, &self.config);
self.recalculate_viewport();
}
@@ -532,9 +616,19 @@ impl CanvasState {
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
}
/// Setzt das aktive Zeichenwerkzeug für den Stift.
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) {
self.current_tool = tool;
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
self.adapt_ink = adapt_ink;
if self.paper_theme != theme {
self.paper_theme = theme;
self.queue.write_buffer(
&self.paper_buffer,
0,
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
);
self.upload_background_mesh();
}
self.update_uniforms();
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -543,7 +637,13 @@ impl CanvasState {
return;
}
self.current_background = background;
let mesh = generate_background_mesh(background);
self.upload_background_mesh();
}
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
fn upload_background_mesh(&mut self) {
let mesh =
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
self.background_vertex_count = mesh.len() as u32;
if mesh.len() > self.background_vertex_capacity {
@@ -566,6 +666,12 @@ impl CanvasState {
pub fn sync_page(&mut self, page: &mut Page) {
page.rebuild_cache_if_dirty();
self.set_background(page.background);
self.image_layer.set_quads(
page.strokes
.iter()
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?)))
.collect(),
);
self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone();
@@ -592,6 +698,12 @@ impl CanvasState {
);
}
/// Lädt Bilder der aktuellen Seite, die noch nicht auf der GPU liegen. Vor jedem
/// Zeichnen aufrufen; ohne Änderungen kostet das nichts.
pub fn prepare_images(&mut self, assets: &crate::assets::AssetStore) {
self.image_layer.prepare(&self.device, &self.queue, assets);
}
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
self.filter.reset();
@@ -673,7 +785,6 @@ impl CanvasState {
&self.spline_config,
);
let mut stroke = stroke;
stroke.tool = self.current_tool;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true;
@@ -769,7 +880,7 @@ impl CanvasState {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
store: wgpu::StoreOp::Store,
},
})],
@@ -781,7 +892,7 @@ impl CanvasState {
// 2D-Kamera Uniforms (Scale & Offset) binden
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
// 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
// 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
render_pass.set_pipeline(&self.triangle_pipeline);
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
render_pass.draw(0..6, 0..1);
@@ -792,7 +903,10 @@ impl CanvasState {
render_pass.draw(0..self.background_vertex_count, 0..1);
}
// 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
// 3. Eingefügte Bilder (unter der Tinte)
self.image_layer.draw(&mut render_pass);
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
render_pass.set_pipeline(&self.strip_pipeline);
if self.committed_vertex_count > 0 {
@@ -804,7 +918,7 @@ impl CanvasState {
}
}
// 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
if self.live_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
render_pass.draw(0..self.live_vertex_count, 0..1);
@@ -858,6 +972,16 @@ impl CanvasState {
mod tests {
use super::*;
#[test]
fn test_choose_present_mode() {
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
assert_eq!(choose_present_mode(&[]), None);
}
#[test]
fn test_pixel_to_ndc_mapping() {
let width = 800.0;
@@ -883,7 +1007,40 @@ mod tests {
#[test]
fn test_camera_uniforms() {
let _u = CameraUniforms::default();
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16);
// Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4
assert_eq!(std::mem::size_of::<CameraUniforms>(), 32);
}
#[test]
fn test_shader_is_valid_and_matches_uniforms() {
// Ein Shaderfehler fiele sonst erst beim Programmstart auf
let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl"))
.unwrap_or_else(|e| panic!("WGSL-Fehler: {e}"));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
assert!(
module.entry_points.iter().any(|ep| ep.name == name),
"Einstiegspunkt {name} fehlt"
);
}
let camera = module
.types
.iter()
.find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms"))
.expect("CameraUniforms fehlt im Shader")
.1;
match camera.inner {
naga::TypeInner::Struct { span, .. } => {
assert_eq!(span as usize, std::mem::size_of::<CameraUniforms>())
}
ref other => panic!("CameraUniforms ist kein Struct: {other:?}"),
}
}
#[test]
+295 -11
View File
@@ -103,6 +103,52 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length()
}
/// Liegt `p` im konvexen Viereck `quad` (Ecken der Reihe nach, beliebiger Umlaufsinn)?
pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
let mut sign = 0.0_f32;
for i in 0..4 {
let a = quad[i];
let b = quad[(i + 1) % 4];
let cross = (b - a).perp_dot(p - a);
if cross.abs() < 1e-6 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
true
}
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt.
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] {
let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (PAGE_WIDTH * 0.8 / natural.x)
.min(PAGE_HEIGHT * 0.6 / natural.y)
.min(1.0);
let size = natural * fit;
let half = size / 2.0;
let center = glam::Vec2::new(
center.x.clamp(half.x, PAGE_WIDTH - half.x),
center.y.clamp(half.y, PAGE_HEIGHT - half.y),
);
let tl = center - half;
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
tl + size,
tl + glam::Vec2::new(0.0, size.y),
]
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -113,6 +159,39 @@ pub enum StrokeTool {
Shape,
}
impl StrokeTool {
/// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und
/// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der
/// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form.
pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool {
match stored {
StrokeTool::Pen if has_shape => StrokeTool::Shape,
StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter,
other => other,
}
}
}
/// Welche Striche der Radierer entfernt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EraserMode {
/// Alle getroffenen Striche
#[default]
All,
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
HighlighterOnly,
}
impl EraserMode {
/// Darf ein Strich dieses Werkzeugs radiert werden?
pub fn erases(self, tool: StrokeTool) -> bool {
match self {
EraserMode::All => true,
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
}
}
}
/// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
@@ -125,6 +204,10 @@ pub struct Stroke {
pub tool: StrokeTool,
pub width: f32,
pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner, siehe `assets`). Die vier Punkte sind dann die
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
pub image: Option<String>,
}
impl Stroke {
@@ -157,9 +240,44 @@ impl Stroke {
tool: StrokeTool::Pen,
width: 5.0,
finalized: true,
image: None,
}
}
/// Erzeugt ein Bildobjekt mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn new_image(id: u64, asset: String, corners: [glam::Vec2; 4]) -> Self {
let points = corners
.iter()
.map(|&position| StylusSample {
position,
pressure: 1.0,
timestamp: 0.0,
})
.collect();
let mut stroke = Self::new(id, points, Vec::new(), [1.0, 1.0, 1.0, 1.0]);
stroke.width = 0.0;
stroke.image = Some(asset);
stroke
}
/// Ist dieses Objekt ein Bild?
pub fn is_image(&self) -> bool {
self.image.is_some()
}
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
if self.image.is_none() || self.points.len() < 4 {
return None;
}
Some([
self.points[0].position,
self.points[1].position,
self.points[2].position,
self.points[3].position,
])
}
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
pub fn finalize(
id: u64,
@@ -193,7 +311,16 @@ impl Stroke {
}
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
if let Some(corners) = self.image_corners() {
if point_in_convex_quad(p, &corners) {
return 0.0;
}
return (0..4)
.map(|i| point_to_segment_distance(p, corners[i], corners[(i + 1) % 4]))
.fold(f32::INFINITY, f32::min);
}
if self.points.is_empty() {
return f32::INFINITY;
}
@@ -315,7 +442,11 @@ impl Stroke {
}
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
/// Bilder behalten ihre Farben.
pub fn set_color(&mut self, new_color: [f32; 4]) {
if self.is_image() {
return;
}
self.color = new_color;
for v in &mut self.vertices {
v.color = new_color;
@@ -429,20 +560,18 @@ impl Page {
}
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
let mut best: Option<(&Stroke, f32)> = None;
let mut best: Option<(&Stroke, (bool, f32))> = None;
for s in &self.strokes {
if !s.aabb.expanded(max_dist).contains_point(pos) {
continue;
}
let d = s.distance_to_point(pos);
if d <= max_dist {
if let Some((_, best_d)) = best {
if d < best_d {
best = Some((s, d));
}
} else {
best = Some((s, d));
let key = (s.is_image(), d);
if best.map_or(true, |(_, best_key)| key < best_key) {
best = Some((s, key));
}
}
}
@@ -469,7 +598,8 @@ impl Page {
}
}
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`,
/// die `mode` zulässt.
///
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
pub fn erase_at(
@@ -477,11 +607,15 @@ impl Page {
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
mode: EraserMode,
) -> Vec<Stroke> {
let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() {
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) {
if !stroke.is_image()
&& mode.erases(stroke.tool)
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
{
hit_indices.push(i);
}
}
@@ -535,6 +669,8 @@ impl Page {
}
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
let sel_box = Aabb::new(
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
@@ -542,7 +678,13 @@ impl Page {
);
self.strokes
.iter()
.filter(|s| s.aabb.intersects(&sel_box))
.filter(|s| {
if s.is_image() {
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
} else {
s.aabb.intersects(&sel_box)
}
})
.map(|s| s.id)
.collect()
}
@@ -1040,7 +1182,7 @@ mod tests {
assert_eq!(page.strokes.len(), 2);
// Radieren bei (30, 12): Trifft nur s1
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0);
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All);
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].id, 1);
assert_eq!(page.strokes.len(), 1);
@@ -1056,6 +1198,148 @@ mod tests {
assert_eq!(page.strokes[0].id, 2);
}
#[test]
fn test_erase_highlighter_only_keeps_handwriting() {
let mut page = Page::new(1, PageBackground::Grid5mm);
let line = |id: u64, tool: StrokeTool| {
let mut stroke = Stroke::new(
id,
vec![
StylusSample {
position: glam::Vec2::new(10.0, 10.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(50.0, 10.0),
pressure: 0.5,
timestamp: 0.01,
},
],
vec![
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
);
stroke.tool = tool;
stroke
};
// Handschrift, Form und Textmarker liegen übereinander
page.add_stroke(line(1, StrokeTool::Pen));
page.add_stroke(line(2, StrokeTool::Highlighter));
page.add_stroke(line(3, StrokeTool::Shape));
let at = glam::Vec2::new(30.0, 10.0);
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert_eq!(
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
vec![1, 3]
);
// Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag
assert!(page
.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly)
.is_empty());
assert!(page.undo());
assert_eq!(page.strokes.len(), 3);
// Normaler Radierer entfernt alles
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All);
assert_eq!(removed.len(), 3);
assert!(page.strokes.is_empty());
}
#[test]
fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center);
let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
assert_eq!(tr.y, tl.y);
// Am Rand eingefügt: wird aufs Blatt geschoben
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0));
for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
}
}
#[test]
fn test_image_objects_hit_test_and_survive_erasing() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(300.0, 100.0),
glam::Vec2::new(300.0, 200.0),
glam::Vec2::new(100.0, 200.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
assert_eq!(page.strokes[0].image_corners(), Some(corners));
// Fläche des Bildes trifft, auch weit weg vom Rand
let inside = glam::Vec2::new(200.0, 150.0);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(1));
assert!(page
.find_stroke_at(glam::Vec2::new(400.0, 150.0), 5.0)
.is_none());
// Notiz auf dem Bild hat beim Antippen Vorrang
let mut ink = Stroke::new(
2,
vec![
StylusSample {
position: glam::Vec2::new(150.0, 150.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(250.0, 152.0),
pressure: 0.5,
timestamp: 0.1,
},
],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
ink.width = 5.0;
page.add_stroke(ink);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(2));
// Radierer entfernt nur die Notiz, nie das Bild
let removed = page.erase_at(inside, 30.0, 12.0, EraserMode::All);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert!(page
.erase_at(glam::Vec2::new(100.0, 100.0), 30.0, 12.0, EraserMode::All)
.is_empty());
// Auswahlrechteck: Bild nur bei vollständigem Einschluss
assert!(page
.find_strokes_in_box(glam::Vec2::new(150.0, 120.0), glam::Vec2::new(250.0, 180.0))
.is_empty());
assert_eq!(
page.find_strokes_in_box(glam::Vec2::new(90.0, 90.0), glam::Vec2::new(310.0, 210.0)),
vec![1]
);
// Umfärben ändert Bilder nicht, Drehen bewegt die Ecken mit
let mut image = page.strokes[0].clone();
image.set_color([1.0, 0.0, 0.0, 1.0]);
assert_eq!(image.color, [1.0, 1.0, 1.0, 1.0]);
image.rotate(glam::Vec2::new(200.0, 150.0), std::f32::consts::FRAC_PI_2);
let rotated = image.image_corners().unwrap();
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
}
#[test]
fn test_page_isolation() {
let mut nb = Notebook::new();
+405 -71
View File
@@ -2,7 +2,7 @@
//!
//! Features:
//! - Hochformat DIN A4 (210 mm x 297 mm)
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink")
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
//! - Zwei Export-Strategien:
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::assets::AssetStore;
use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
@@ -109,14 +110,16 @@ pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
}
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
pub fn export_notebook_to_pdf<P: AsRef<Path>>(
notebook: &Notebook,
path: P,
strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<(), Box<dyn Error>> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let doc = generate_pdf_document(notebook, strategy)?;
let doc = generate_pdf_document(notebook, strategy, assets)?;
doc.save(&mut writer)?;
Ok(())
}
@@ -125,8 +128,9 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
pub fn export_notebook_to_bytes(
notebook: &Notebook,
strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy)?;
let doc = generate_pdf_document(notebook, strategy, assets)?;
let bytes = doc.save_to_bytes()?;
Ok(bytes)
}
@@ -135,6 +139,7 @@ pub fn export_notebook_to_bytes(
pub fn generate_pdf_document(
notebook: &Notebook,
strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into());
@@ -159,6 +164,9 @@ pub fn generate_pdf_document(
let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
render_image_layer(&page1_ref.add_layer("Images"), &notebook.pages[0], assets);
}
let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
@@ -169,6 +177,9 @@ pub fn generate_pdf_document(
let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background);
if page.strokes.iter().any(Stroke::is_image) {
render_image_layer(&page_ref.add_layer("Images"), page, assets);
}
let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy);
@@ -268,6 +279,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
for stroke in &page.strokes {
if stroke.is_image() {
continue;
}
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
} else {
@@ -283,6 +297,160 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
}
}
/// Auflösung, mit der Bilder höchstens eingebettet werden (Druckqualität).
pub const PDF_IMAGE_DPI: f32 = 300.0;
/// Seitenkoordinate in PDF-Punkten (ohne Begrenzung auf das Blatt, Ursprung unten links).
fn page_to_pdf_pt(p: glam::Vec2) -> glam::Vec2 {
const PT_PER_MM: f32 = 72.0 / 25.4;
glam::Vec2::new(
p.x / PAGE_WIDTH * A4_WIDTH_MM * PT_PER_MM,
(1.0 - p.y / PAGE_HEIGHT) * A4_HEIGHT_MM * PT_PER_MM,
)
}
/// PDF-Transformationsmatrix, die das Einheitsquadrat eines Bildes (Ursprung unten links)
/// auf die Bildecken oben links, oben rechts, unten rechts, unten links abbildet.
pub fn image_matrix(corners: &[glam::Vec2; 4]) -> [f32; 6] {
let [tl, _, br, bl] = corners.map(page_to_pdf_pt);
let x_axis = br - bl;
let y_axis = tl - bl;
[x_axis.x, x_axis.y, y_axis.x, y_axis.y, bl.x, bl.y]
}
/// Anzahl der Bildkomponenten eines JPEG laut Frame-Header (1 = Graustufen, 3 = Farbe,
/// 4 = CMYK), `None` bei unlesbarem Header.
fn jpeg_components(bytes: &[u8]) -> Option<u8> {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
return None;
}
let marker = bytes[i + 1];
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
// SOF0–SOF15 außer DHT (C4), JPG (C8) und DAC (CC)
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
return bytes.get(i + 9).copied();
}
i += 2 + len;
}
None
}
/// Bilddaten für das PDF: JPEG-Dateien werden unverändert eingebettet, alles andere als
/// RGB auf weißem Grund (Transparenz wird mit dem Papier verrechnet), höchstens mit
/// `PDF_IMAGE_DPI` bezogen auf die Größe auf der Seite.
fn pdf_image_xobject(
assets: &AssetStore,
asset: &str,
corners: &[glam::Vec2; 4],
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
let bytes = assets.read_bytes(asset)?;
let decoded = image::load_from_memory(&bytes)?;
if asset.ends_with(".jpg") {
let color_space = match jpeg_components(&bytes) {
Some(1) => Some(ColorSpace::Greyscale),
Some(3) => Some(ColorSpace::Rgb),
_ => None,
};
if let Some(color_space) = color_space {
return Ok(ImageXObject {
width: Px(decoded.width() as usize),
height: Px(decoded.height() as usize),
color_space,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: bytes,
image_filter: Some(ImageFilter::DCT),
smask: None,
clipping_bbox: None,
});
}
}
// Größe auf der Seite in Zoll → benötigte Pixel
let width_in = (corners[1] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let height_in = (corners[3] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let max_w = ((width_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let max_h = ((height_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let decoded = if decoded.width() > max_w || decoded.height() > max_h {
decoded.resize(max_w, max_h, image::imageops::FilterType::Triangle)
} else {
decoded
};
let rgba = decoded.into_rgba8();
let mut rgb = Vec::with_capacity((rgba.width() * rgba.height() * 3) as usize);
for px in rgba.pixels() {
let alpha = px[3] as u32;
for c in &px.0[..3] {
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
}
}
Ok(ImageXObject {
width: Px(rgba.width() as usize),
height: Px(rgba.height() as usize),
color_space: ColorSpace::Rgb,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: rgb,
image_filter: None,
smask: None,
clipping_bbox: None,
})
}
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
fn render_image_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
for stroke in &page.strokes {
let (Some(asset), Some(corners)) = (stroke.image.as_deref(), stroke.image_corners()) else {
continue;
};
let xobject = match assets.map(|a| pdf_image_xobject(a, asset, &corners)) {
Some(Ok(xobject)) => xobject,
other => {
if let Some(Err(err)) = other {
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
}
printpdf::ImageXObject {
width: printpdf::Px(1),
height: printpdf::Px(1),
color_space: printpdf::ColorSpace::Rgb,
bits_per_component: printpdf::ColorBits::Bit8,
interpolate: false,
image_data: vec![200, 200, 200],
image_filter: None,
smask: None,
clipping_bbox: None,
}
}
};
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
layer.save_graphics_state();
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(&corners)));
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
printpdf::Image::from(xobject).add_to_layer(
layer.clone(),
printpdf::ImageTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / width),
scale_y: Some(1.0 / height),
..Default::default()
},
);
layer.restore_graphics_state();
}
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape(
@@ -329,6 +497,59 @@ fn render_recognized_shape(
layer.add_polygon(poly);
}
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
let ring = (0..72)
.map(|i| {
let t = i as f32 / 72.0 * std::f32::consts::TAU;
let p =
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
})
.collect();
layer.add_polygon(Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
});
}
RecognizedShape::Arrow { start, end, double } => {
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let start_head =
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let line_start = if *double { start_head.0 } else { *start };
let to_pdf = |p: glam::Vec2| {
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
};
layer.add_line(Line {
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
is_closed: false,
});
layer.set_fill_color(stroke_color);
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in heads {
layer.add_polygon(Polygon {
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
RecognizedShape::Rectangle { corners } => {
let mut ring = Vec::with_capacity(4);
for c in corners {
@@ -412,76 +633,58 @@ fn render_recognized_shape(
y_axis_end,
with_arrows,
} => {
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = crate::geometry::arrow_head(
*y_axis_end,
y_dir,
PDF_SHAPE_HALF_WIDTH,
(*y_axis_end - *origin).length(),
);
let x_arrow = crate::geometry::arrow_head(
*x_axis_end,
x_dir,
PDF_SHAPE_HALF_WIDTH,
(*x_axis_end - *origin).length(),
);
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
// Achse 1 (Y)
let line_y = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(yx), Mm(yy)), false),
],
is_closed: false,
};
layer.add_line(line_y);
// Achse 2 (X)
let line_x = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(xx), Mm(xy)), false),
],
is_closed: false,
};
layer.add_line(line_x);
for end in [y_line_end, x_line_end] {
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(ex), Mm(ey)), false),
],
is_closed: false,
});
}
// Pfeilspitzen als gefüllte Dreiecke
if *with_arrows {
layer.set_fill_color(stroke_color);
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let arrow_len_page = 25.0;
let arrow_w_page = 16.0;
let base_y = *y_axis_end - y_dir * arrow_len_page;
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
let arrow_poly_y = Polygon {
rings: vec![vec![
(Point::new(Mm(tx), Mm(ty)), false),
(Point::new(Mm(lx), Mm(ly)), false),
(Point::new(Mm(rx), Mm(ry)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_y);
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let base_x = *x_axis_end - x_dir * arrow_len_page;
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
let arrow_poly_x = Polygon {
rings: vec![vec![
(Point::new(Mm(tx_x), Mm(ty_x)), false),
(Point::new(Mm(lx_x), Mm(ly_x)), false),
(Point::new(Mm(rx_x), Mm(ry_x)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_x);
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let ring = [tip, left, right]
.iter()
.map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(px), Mm(py)), false)
})
.collect();
layer.add_polygon(Polygon {
rings: vec![ring],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
}
}
@@ -759,7 +962,7 @@ mod tests {
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res =
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon);
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon, None);
assert!(
export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein"
@@ -785,7 +988,7 @@ mod tests {
);
// 3. Export als Bytes testen (Strategie B: BezierCurve)
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve, None);
assert!(bytes_res.is_ok());
let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024);
@@ -838,6 +1041,27 @@ mod tests {
);
page.add_stroke(coords_stroke);
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(1400.0, 600.0),
radius_x: 300.0,
radius_y: 150.0,
rotation: 0.4,
},
);
page.add_stroke(ellipse_stroke);
for (id, double) in [(41, false), (42, true)] {
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
double,
},
);
page.add_stroke(arrow_stroke);
}
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
crate::geometry::RecognizedShape::Triangle {
corners: [
@@ -887,13 +1111,123 @@ mod tests {
);
page.add_stroke(octahedron_stroke);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None);
assert!(bytes_res.is_ok(), "Shape export must succeed");
let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1."));
}
#[test]
fn test_image_matrix_maps_unit_square_to_corners() {
// Bild über die volle Seitenbreite, oberes Viertel der Seite
let corners = [
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(PAGE_WIDTH, 0.0),
glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT / 4.0),
glam::Vec2::new(0.0, PAGE_HEIGHT / 4.0),
];
let [a, b, c, d, e, f] = image_matrix(&corners);
let page_w_pt = A4_WIDTH_MM * 72.0 / 25.4;
let page_h_pt = A4_HEIGHT_MM * 72.0 / 25.4;
let map = |u: f32, v: f32| (a * u + c * v + e, b * u + d * v + f);
let close = |(x, y): (f32, f32), (ex, ey): (f32, f32)| {
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01
};
// Unten links des Bildes, oben rechts des Bildes (PDF-Ursprung unten links)
assert!(close(map(0.0, 0.0), (0.0, page_h_pt * 0.75)));
assert!(close(map(1.0, 1.0), (page_w_pt, page_h_pt)));
// Gedrehtes Bild: Achsen folgen den Ecken
let rotated = [
glam::Vec2::new(500.0, 500.0),
glam::Vec2::new(500.0, 900.0),
glam::Vec2::new(300.0, 900.0),
glam::Vec2::new(300.0, 500.0),
];
let m = image_matrix(&rotated);
assert!(
m[0].abs() < 1e-3 && m[1] < 0.0,
"x-Achse zeigt nach unten: {m:?}"
);
}
#[test]
fn test_pdf_export_embeds_images() {
let dir = tempfile::tempdir().unwrap();
let assets = AssetStore::new(dir.path());
let jpg = crate::assets::tests::sample_image_bytes(64, 48, image::ImageFormat::Jpeg);
let png = crate::assets::tests::sample_image_bytes(30, 20, image::ImageFormat::Png);
let jpg_asset = assets.import_bytes(&jpg).unwrap().asset;
let png_asset = assets.import_bytes(&png).unwrap().asset;
let mut notebook = Notebook::new();
let page = notebook.current_page_mut();
for (id, asset) in [
(1, jpg_asset),
(2, png_asset),
(3, format!("{}.png", "0".repeat(64))),
] {
let y = id as f32 * 400.0;
page.strokes.push(Stroke::new_image(
id,
asset,
[
glam::Vec2::new(100.0, y),
glam::Vec2::new(700.0, y),
glam::Vec2::new(700.0, y + 300.0),
glam::Vec2::new(100.0, y + 300.0),
],
));
}
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets))
.expect("Export mit Bildern");
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(
contains(b"/DCTDecode"),
"JPEG wird nicht unverändert eingebettet"
);
// JPEG-Daten liegen byte-identisch im PDF
assert!(contains(&jpg[..64]));
assert!(
contains(b"/Images") || contains(b"(Images)"),
"Bild-Ebene fehlt"
);
let image_objects = bytes
.windows(b"/Subtype/Image".len())
.filter(|w| *w == b"/Subtype/Image")
.count()
+ bytes
.windows(b"/Subtype /Image".len())
.filter(|w| *w == b"/Subtype /Image")
.count();
// JPEG, PNG und Ersatzfläche für die fehlende Datei
assert_eq!(image_objects, 3);
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_jpeg_component_count() {
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
assert_eq!(jpeg_components(&color), Some(3));
let gray = {
let img = image::GrayImage::from_pixel(8, 8, image::Luma([90]));
let mut out = Vec::new();
image::DynamicImage::ImageLuma8(img)
.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.unwrap();
out
};
assert_eq!(jpeg_components(&gray), Some(1));
assert_eq!(jpeg_components(b"\xFF\xD8kaputt"), None);
}
#[test]
fn test_sanitize_filename_component() {
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
+196
View File
@@ -0,0 +1,196 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder.
//!
//! 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 unterstützte Bilder des
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
use std::path::{Path, PathBuf};
use crate::assets::is_supported_image_path;
/// Ein Eintrag der Dateiliste.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
}
/// Zustand der Dateiauswahl.
#[derive(Debug, Clone, PartialEq)]
pub struct FilePicker {
pub dir: PathBuf,
pub entries: Vec<PickerEntry>,
/// Ordner nicht lesbar
pub error: Option<String>,
}
impl FilePicker {
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner, sonst im Home-Ordner.
pub fn open(dir: Option<&Path>) -> Self {
let start = dir
.filter(|d| d.is_dir())
.map(Path::to_path_buf)
.or_else(|| shortcuts().into_iter().map(|(_, p)| p).find(|p| p.is_dir()))
.unwrap_or_else(|| PathBuf::from("/"));
let mut picker = Self {
dir: start.clone(),
entries: Vec::new(),
error: None,
};
picker.navigate(&start);
picker
}
/// Wechselt in `dir` und liest den Inhalt neu ein.
pub fn navigate(&mut self, dir: &Path) {
match list_dir(dir) {
Ok(entries) => {
self.dir = dir.to_path_buf();
self.entries = entries;
self.error = None;
}
Err(err) => {
self.error = Some(format!(
"'{}' kann nicht geöffnet werden: {err}",
dir.display()
));
}
}
}
/// Eine Ebene nach oben.
pub fn go_up(&mut self) {
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
self.navigate(&parent);
}
}
}
/// Ordner und unterstützte Bilder in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
pub fn list_dir(dir: &Path) -> std::io::Result<Vec<PickerEntry>> {
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
return None;
}
let path = entry.path();
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
let is_dir = path.is_dir();
(is_dir || is_supported_image_path(&path)).then_some(PickerEntry { name, path, is_dir })
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let user_dirs =
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
parse_user_dir(&user_dirs, key, &home)
.into_iter()
.chain(fallbacks.iter().map(|f| home.join(f)))
.find(|p| p.is_dir() && *p != home)
};
let mut list = Vec::new();
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
list.push(("🖼 Bilder", p));
}
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
list.push(("⬇ Downloads", p));
}
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
list.push(("🖥 Schreibtisch", p));
}
list.push(("🏠 Persönlicher Ordner", home));
list
}
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
content.lines().find_map(|line| {
let value = line
.trim()
.strip_prefix(key)?
.trim_start()
.strip_prefix('=')?;
let value = value.trim().trim_matches('"');
let path = match value.strip_prefix("$HOME") {
Some(rest) => home.join(rest.trim_start_matches('/')),
None => PathBuf::from(value),
};
path.is_absolute().then_some(path)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_dir_sorts_and_filters() {
let dir = tempfile::tempdir().unwrap();
for name in ["b.png", "A.jpg", "notiz.txt", ".versteckt.png", "c.webp"] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
std::fs::create_dir(dir.path().join(".cache")).unwrap();
let names: Vec<String> = list_dir(dir.path())
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert_eq!(names, ["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]);
}
#[test]
fn test_navigation_and_errors() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("Fotos");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
let mut picker = FilePicker::open(Some(&sub));
assert_eq!(picker.dir, sub);
assert_eq!(picker.entries.len(), 1);
picker.go_up();
assert_eq!(picker.dir, dir.path());
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
picker.navigate(&dir.path().join("gibt-es-nicht"));
assert!(picker.error.is_some());
assert_eq!(picker.dir, dir.path());
}
#[test]
fn test_parse_user_dirs() {
let home = Path::new("/home/anna");
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
assert_eq!(
parse_user_dir(content, "XDG_PICTURES_DIR", home),
Some(PathBuf::from("/home/anna/Bilder"))
);
assert_eq!(
parse_user_dir(content, "XDG_MUSIC_DIR", home),
Some(PathBuf::from("/daten/Musik"))
);
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
}
}
+1373 -145
View File
File diff suppressed because it is too large Load Diff
+363
View File
@@ -0,0 +1,363 @@
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
//! Papier und vor der Tinte.
//!
//! Texturen werden beim ersten Bedarf aus dem Asset-Ordner geladen (verkleinert auf
//! `assets::MAX_DISPLAY_DIMENSION`) und mit Mipmaps versehen, damit verkleinerte Bilder
//! beim Herauszoomen nicht flimmern.
use std::collections::{HashMap, HashSet};
use crate::assets::{AssetStore, RgbaImage, MAX_DISPLAY_DIMENSION};
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ImageVertex {
pub position: [f32; 2],
pub uv: [f32; 2],
}
impl ImageVertex {
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBUTES,
}
}
}
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
let v = |i: usize, uv: [f32; 2]| ImageVertex {
position: corners[i].to_array(),
uv,
};
let (tl, tr, br, bl) = (
v(0, [0.0, 0.0]),
v(1, [1.0, 0.0]),
v(2, [1.0, 1.0]),
v(3, [0.0, 1.0]),
);
[tl, tr, bl, bl, tr, br]
}
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
let mut levels = vec![base.clone()];
let Some(mut current) =
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
else {
return levels;
};
while current.width() > 1 || current.height() > 1 {
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
current = image::imageops::resize(&current, w, h, image::imageops::FilterType::Triangle);
levels.push(RgbaImage {
width: w,
height: h,
pixels: current.as_raw().clone(),
});
}
levels
}
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
const MISSING: &str = "";
/// So viele Texturen bleiben höchstens im Speicher, auch wenn sie auf der aktuellen Seite
/// nicht mehr gebraucht werden (schnelles Zurückblättern).
const TEXTURE_CACHE_LIMIT: usize = 24;
pub struct ImageLayer {
pipeline: wgpu::RenderPipeline,
texture_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
textures: HashMap<String, wgpu::BindGroup>,
/// Bilder der aktuellen Seite: Asset-Name und Ecken
quads: Vec<(String, [glam::Vec2; 4])>,
vertex_buffer: Option<wgpu::Buffer>,
needs_upload: bool,
}
impl ImageLayer {
pub fn new(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
camera_layout: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
) -> Self {
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Texture Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Image Pipeline Layout"),
bind_group_layouts: &[camera_layout, &texture_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Image Render Pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: "vs_image",
buffers: &[ImageVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: "fs_image",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Image Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
pipeline,
texture_layout,
sampler,
textures: HashMap::new(),
quads: Vec::new(),
vertex_buffer: None,
needs_upload: false,
}
}
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
if quads != self.quads {
self.quads = quads;
self.needs_upload = true;
}
}
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
/// ohne Änderungen kostet das nichts.
pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, assets: &AssetStore) {
if !self.needs_upload {
return;
}
self.needs_upload = false;
let missing: Vec<String> = self
.quads
.iter()
.map(|(asset, _)| asset)
.filter(|asset| !self.textures.contains_key(*asset))
.cloned()
.collect();
for asset in missing {
if self.textures.contains_key(&asset) {
continue;
}
let bind_group = match assets.load_rgba(&asset, MAX_DISPLAY_DIMENSION) {
Ok(image) => self.upload_texture(device, queue, &asset, &image),
Err(err) => {
eprintln!("[Bilder] '{asset}' kann nicht angezeigt werden: {err}");
self.missing_texture(device, queue)
}
};
self.textures.insert(asset, bind_group);
}
// Nicht mehr benötigte Texturen freigeben, wenn der Speicher voll wird
if self.textures.len() > TEXTURE_CACHE_LIMIT {
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
self.textures.retain(|name, _| used.contains(name));
}
let vertices: Vec<ImageVertex> = self
.quads
.iter()
.flat_map(|(_, corners)| quad_vertices(corners))
.collect();
if vertices.is_empty() {
return;
}
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
let too_small = self
.vertex_buffer
.as_ref()
.map_or(true, |b| b.size() < bytes.len() as u64);
if too_small {
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Image Vertex Buffer"),
size: (bytes.len() as u64 * 2).max(1024),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
if let Some(buffer) = &self.vertex_buffer {
queue.write_buffer(buffer, 0, bytes);
}
}
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
let Some(buffer) = &self.vertex_buffer else {
return;
};
if self.quads.is_empty() || self.needs_upload {
return;
}
render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in self.quads.iter().enumerate() {
let Some(bind_group) = self.textures.get(asset) else {
continue;
};
render_pass.set_bind_group(1, bind_group, &[]);
let start = (i * 6) as u32;
render_pass.draw(start..start + 6, 0..1);
}
}
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
// Hellgraue Fläche statt eines unsichtbaren Bildes
let gray = RgbaImage {
width: 1,
height: 1,
pixels: vec![200, 200, 200, 255],
};
self.upload_texture(device, queue, MISSING, &gray)
}
fn upload_texture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
image: &RgbaImage,
) -> wgpu::BindGroup {
let levels = mip_chain(image);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: levels.len() as u32,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
for (level, mip) in levels.iter().enumerate() {
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &texture,
mip_level: level as u32,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&mip.pixels,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(4 * mip.width),
rows_per_image: Some(mip.height),
},
wgpu::Extent3d {
width: mip.width,
height: mip.height,
depth_or_array_layers: 1,
},
);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Image Texture Bind Group"),
layout: &self.texture_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quad_vertices_map_corners_to_texture_corners() {
let corners = [
glam::Vec2::new(10.0, 10.0),
glam::Vec2::new(50.0, 10.0),
glam::Vec2::new(50.0, 30.0),
glam::Vec2::new(10.0, 30.0),
];
let quad = quad_vertices(&corners);
for v in quad {
// Texturkoordinate entspricht der relativen Lage im Rechteck
let u = (v.position[0] - 10.0) / 40.0;
let w = (v.position[1] - 10.0) / 20.0;
assert_eq!(v.uv, [u, w]);
}
// Beide Dreiecke decken alle vier Ecken ab
for c in corners {
assert!(quad.iter().any(|v| v.position == c.to_array()));
}
}
#[test]
fn test_mip_chain_halves_down_to_one_pixel() {
let base = RgbaImage {
width: 8,
height: 3,
pixels: vec![128; 8 * 3 * 4],
};
let levels = mip_chain(&base);
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
for l in &levels {
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
}
}
}
+4
View File
@@ -1,13 +1,17 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod assets;
pub mod autosave;
pub mod background;
pub mod canvas;
pub mod document;
pub mod export;
pub mod file_picker;
pub mod filter;
pub mod geometry;
pub mod history;
pub mod image_layer;
pub mod platform;
pub mod selection;
pub mod settings;
pub mod spline;
+349 -80
View File
@@ -9,17 +9,20 @@ use winit::{
event::{
ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent,
},
event_loop::{ControlFlow, EventLoop},
event_loop::{ControlFlow, EventLoopBuilder},
keyboard::{Key, ModifiersState, NamedKey},
platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11},
window::{Window, WindowBuilder},
};
use glam::Vec2;
use ink_notes::assets::{AssetStore, ImportedImage};
use ink_notes::autosave::{AutosaveController, AutosaveStatus};
use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
use ink_notes::geometry::{self, RecognizedShape};
use ink_notes::platform::{self, DisplayBackend};
use ink_notes::selection::{
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
};
@@ -29,6 +32,19 @@ use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController;
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) {
if let Some(path) = path {
if let Err(err) = settings.save_to(path) {
eprintln!(
"[Einstellungen] Konnte '{}' nicht speichern: {err}",
path.display()
);
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
}
}
}
fn update_window_title(
window: &Window,
notebook: &Notebook,
@@ -55,21 +71,6 @@ fn update_window_title(
window.set_title(&title);
}
/// Prüft, ob ein Bildschirmpunkt (physische Pixel) auf einem UI-Element liegt.
///
/// egui arbeitet in logischen Punkten; Touch- und Stift-Positionen kommen in physischen
/// Pixeln. `layer_id_at` erfasst jedes egui-Element des letzten Frames (Leiste, Popover,
/// Fenster), auch wenn egui den Zeiger selbst noch nicht kennt (Stift, erster Touch).
fn is_screen_point_in_ui(
egui_ctx: &egui::Context,
ui_state: &UiState,
screen_pos: (f32, f32),
scale: f32,
) -> bool {
let pos = egui::pos2(screen_pos.0 / scale, screen_pos.1 / scale);
ui_state.is_point_in_ui(pos.x, pos.y) || egui_ctx.layer_id_at(pos).is_some()
}
/// Exportiert das Notizbuch im Hintergrund als PDF. Noch nicht geladene Seiten werden vorher
/// von der Festplatte nachgeladen; das Ergebnis geht über `result_tx` an den Event-Loop zurück.
fn start_pdf_export(
@@ -91,8 +92,14 @@ fn start_pdf_export(
let result = storage::load_all_pages(&storage_dir, &mut notebook_clone)
.map_err(|err| err.to_string())
.and_then(|()| {
export_notebook_to_pdf(&notebook_clone, &export_path, ExportStrategy::FilledPolygon)
.map_err(|err| err.to_string())
let assets = AssetStore::new(&storage_dir);
export_notebook_to_pdf(
&notebook_clone,
&export_path,
ExportStrategy::FilledPolygon,
Some(&assets),
)
.map_err(|err| err.to_string())
});
match &result {
Ok(()) => println!(
@@ -134,7 +141,8 @@ fn commit_stroke_to_notebook(
canvas.finish_stroke(new_id)
};
if let Some(stroke) = stroke_opt {
if let Some(mut stroke) = stroke_opt {
stroke.tool = ui_state.active_tool.stroke_tool(stroke.shape.is_some());
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
@@ -151,8 +159,228 @@ fn commit_stroke_to_notebook(
}
}
/// Zwischenablage: interne Objekte (Strg+C in ink-notes) und Bilder aus der
/// Systemzwischenablage (Screenshots, aus anderen Programmen kopierte Bilder).
struct ClipboardBridge {
system: Option<arboard::Clipboard>,
/// Prüfsumme des Systembildes zum Zeitpunkt des letzten internen Kopierens: Strg+V fügt
/// das Systembild nur ein, wenn es danach kopiert wurde (die jüngste Kopie gewinnt)
image_hash_at_copy: Option<[u8; 32]>,
/// Notizbuch, aus dem kopiert wurde (für Bilder beim Einfügen in ein anderes Notizbuch)
source_dir: Option<PathBuf>,
}
impl ClipboardBridge {
fn new() -> Self {
Self {
system: None,
image_hash_at_copy: None,
source_dir: None,
}
}
/// Bild in der Systemzwischenablage samt Prüfsumme.
fn system_image(&mut self) -> Option<(arboard::ImageData<'static>, [u8; 32])> {
if self.system.is_none() {
self.system = arboard::Clipboard::new()
.map_err(|e| eprintln!("[Zwischenablage] Nicht verfügbar: {e}"))
.ok();
}
let image = self.system.as_mut()?.get_image().ok()?;
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update((image.width as u64).to_le_bytes());
hasher.update((image.height as u64).to_le_bytes());
hasher.update(&image.bytes);
let hash: [u8; 32] = hasher.finalize().into();
Some((image.to_owned_img(), hash))
}
/// Merkt sich beim internen Kopieren das aktuelle Systembild und das Quell-Notizbuch.
fn note_internal_copy(&mut self, storage_dir: &Path) {
self.image_hash_at_copy = self.system_image().map(|(_, hash)| hash);
self.source_dir = Some(storage_dir.to_path_buf());
}
}
/// Fügt ein importiertes Bild um `center` (Seitenkoordinaten) auf der aktuellen Seite ein,
/// wählt es aus und schaltet auf das Auswahlwerkzeug, damit es gleich verschoben und
/// skaliert werden kann.
#[allow(clippy::too_many_arguments)]
fn insert_image_on_page(
imported: ImportedImage,
center: Vec2,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
let id = notebook.next_stroke_id();
let corners = ink_notes::document::place_image(imported.width, imported.height, center);
let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image(
id,
imported.asset,
corners,
));
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
selection_state.select_single(id);
ui_state.active_tool = ActiveTool::Select;
ui_state.set_export_message(format!(
"Bild eingefügt ({} × {} px)",
imported.width, imported.height
));
}
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
canvas.screen_to_page_clamped(
canvas.size.width as f32 / 2.0,
canvas.size.height as f32 / 2.0,
)
}
/// Strg+V bzw. „Einfügen“: ein neu kopiertes Systembild hat Vorrang, sonst werden die intern
/// kopierten Objekte eingefügt. Gibt die Meldung für die Statuszeile zurück.
#[allow(clippy::too_many_arguments)]
fn paste(
clipboard: &mut ClipboardBridge,
storage_dir: &Path,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
if notebook.current_page().is_readonly() {
return;
}
if let Some((image, hash)) = clipboard.system_image() {
// Die jüngste Kopie gewinnt: das Systembild nur, wenn es nach dem letzten internen
// Kopieren in die Zwischenablage kam
if selection_state.clipboard.is_empty() || clipboard.image_hash_at_copy != Some(hash) {
let assets = AssetStore::new(storage_dir);
match assets.import_rgba(
image.width as u32,
image.height as u32,
image.bytes.into_owned(),
) {
Ok(imported) => {
let center = visible_page_center(canvas);
insert_image_on_page(
imported,
center,
canvas,
notebook,
manifest,
autosave,
selection_state,
ui_state,
);
}
Err(err) => ui_state.set_export_message(format!("Bild nicht eingefügt: {err}")),
}
return;
}
}
// Bilder aus einem anderen Notizbuch mitnehmen
if let Some(source) = clipboard
.source_dir
.as_deref()
.filter(|d| *d != storage_dir)
{
let from = AssetStore::new(source);
let to = AssetStore::new(storage_dir);
for asset in selection_state
.clipboard
.iter()
.filter_map(|s| s.image.as_deref())
{
if let Err(err) = to.copy_from(&from, asset) {
eprintln!("[Zwischenablage] Bild '{asset}' nicht übernommen: {err}");
}
}
}
let mut next_id = notebook.next_stroke_id;
let new_ids = selection_state.paste_from_clipboard(notebook.current_page_mut(), &mut || {
let id = next_id;
next_id += 1;
id
});
notebook.next_stroke_id = next_id;
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
}
}
/// Liest eine Bilddatei und fügt sie um `center` ein.
#[allow(clippy::too_many_arguments)]
fn insert_image_file(
path: &Path,
center: Vec2,
storage_dir: &Path,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
ui_state: &mut UiState,
) {
if notebook.current_page().is_readonly() {
ui_state.set_export_message("Seite ist schreibgeschützt".to_string());
return;
}
let result = std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|bytes| {
AssetStore::new(storage_dir)
.import_bytes(&bytes)
.map_err(|e| e.to_string())
});
match result {
Ok(imported) => insert_image_on_page(
imported,
center,
canvas,
notebook,
manifest,
autosave,
selection_state,
ui_state,
),
Err(err) => ui_state.set_export_message(format!(
"'{}' nicht eingefügt: {err}",
path.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default()
)),
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let event_loop = EventLoop::new()?;
// Anzeigesystem wählen: Unter WSL (WSLg) bricht Wayland ab, X11 (Xwayland) läuft stabil
let mut event_loop_builder = EventLoopBuilder::new();
match platform::display_backend_from_env() {
DisplayBackend::X11 => {
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
event_loop_builder.with_x11();
}
DisplayBackend::Wayland => {
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
event_loop_builder.with_wayland();
}
DisplayBackend::Automatic => {}
}
let event_loop = event_loop_builder.build()?;
let window = Arc::new(
WindowBuilder::new()
.with_title("ink-notes (Latenzoptimiertes Linux-Inking)")
@@ -160,7 +388,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.build(&event_loop)?,
);
let mut canvas = pollster::block_on(CanvasState::new(window.clone()))?;
let mut canvas = match pollster::block_on(CanvasState::new(window.clone())) {
Ok(canvas) => canvas,
Err(err) => {
eprintln!("[Grafik-Fehler] {err}");
std::process::exit(1);
}
};
let mut tablet_manager = match octotablet::builder::Builder::default().build_shared(&window) {
Ok(mgr) => {
@@ -253,6 +487,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(UiSettings::load_from)
.unwrap_or_default();
ui_state.sidebar_side = ui_settings.sidebar_side;
ui_state.paper_theme = ui_settings.paper_theme;
ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors;
ui_state.pressure_curve = ui_settings.pressure_curve;
ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks;
@@ -261,6 +498,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.set_export_message(warning);
}
let mut selection_state = SelectionState::new();
let mut clipboard = ClipboardBridge::new();
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
Ok(lock) => {
@@ -270,7 +508,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Err(storage::StorageError::AlreadyLocked(msg)) => {
eprintln!("[Storage-Lock] {msg}");
autosave.set_read_only(true);
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
None
}
Err(e) => {
@@ -284,6 +522,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut());
canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
let start_time = Instant::now();
let mut cursor_screen_pos = (0.0_f32, 0.0_f32);
@@ -465,7 +704,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
cursor_screen_pos = (sx, sy);
if let Some(p) = pose.pressure.get() {
current_pressure = p;
current_pressure = ui_state.pressure_curve.apply(p);
ui_state.last_raw_pressure = Some(p);
}
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
@@ -477,7 +717,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.events
.push(egui::Event::PointerMoved(ui_pos));
if stylus_ui_capture
|| is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
{
window.request_redraw();
}
@@ -492,8 +732,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(
page_pt,
eraser_radius,
stroke_max_radius,
);
stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -532,7 +771,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
stylus_tool_id = Some(tool.id());
let (sx, sy) = stylus_screen_pos;
if is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
let ui_pos = egui::pos2(sx / scale, sy / scale);
@@ -560,8 +799,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(
page_pt,
eraser_radius,
stroke_max_radius,
);
stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -710,7 +948,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|| is_screen_point_in_ui(
|| ui::is_screen_point_in_ui(
&egui_ctx,
&ui_state,
cursor_screen_pos,
@@ -732,6 +970,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
WindowEvent::DroppedFile(path) => {
if ink_notes::assets::is_supported_image_path(&path) {
let (sx, sy) = cursor_screen_pos;
let center = canvas.screen_to_page_clamped(sx, sy);
insert_image_file(
&path, center, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
} else {
ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) können eingefügt werden".to_string());
}
window.request_redraw();
}
WindowEvent::Focused(false) => {
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
if is_drawing {
@@ -856,6 +1110,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Key::Character(c) if c.eq_ignore_ascii_case("c") && ctrl => {
let count = selection_state.copy_to_clipboard(notebook.current_page());
if count > 0 {
clipboard.note_internal_copy(&storage_dir);
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
window.request_redraw();
}
@@ -863,23 +1118,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Einfügen: Strg + V
Key::Character(c) if c.eq_ignore_ascii_case("v") && ctrl => {
let mut next_id = notebook.next_stroke_id;
let new_ids = selection_state.paste_from_clipboard(
notebook.current_page_mut(),
&mut || {
let id = next_id;
next_id += 1;
id
},
paste(
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
notebook.next_stroke_id = next_id;
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw();
}
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
// Bild einfügen: Strg + I
Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => {
ui_state.open_image_picker();
window.request_redraw();
}
// Löschen: Entf oder Backspace
@@ -1181,7 +1431,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let (sx, sy) = cursor_screen_pos;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -1195,7 +1445,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Objektauswahl & Transformation im Select-Modus
if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(sx, sy);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -1449,7 +1699,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if is_erasing {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -1510,15 +1760,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let now = start_time.elapsed().as_secs_f64();
let tx = touch.location.x as f32;
let ty = touch.location.y as f32;
let pressure = match touch.force {
Some(winit::event::Force::Normalized(f)) => f as f32,
let raw_pressure = match touch.force {
Some(winit::event::Force::Normalized(f)) => Some(f as f32),
Some(winit::event::Force::Calibrated {
force,
max_possible_force,
..
}) => (force / max_possible_force).clamp(0.0, 1.0) as f32,
None => 0.5,
}) => Some((force / max_possible_force).clamp(0.0, 1.0) as f32),
None => None,
};
// Druckkurve nur bei echtem Druck; Finger ohne Druck zeichnen mittelstark
let pressure =
raw_pressure.map_or(0.5, |p| ui_state.pressure_curve.apply(p));
let is_eraser_action = ui_state.active_tool == ActiveTool::Eraser;
@@ -1539,7 +1792,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match touch.phase {
TouchPhase::Started => {
// Event-Isolation für Touch-Eingaben
let is_touch_in_ui = is_screen_point_in_ui(
let is_touch_in_ui = ui::is_screen_point_in_ui(
&egui_ctx,
&ui_state,
(tx, ty),
@@ -1585,7 +1838,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -1595,7 +1848,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
} else if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(tx, ty);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -1726,7 +1979,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} else if is_erasing {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let page = notebook.current_page_mut();
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius, ui_state.eraser_mode);
if !removed.is_empty() {
canvas.sync_page(page);
autosave.mark_dirty(&notebook, &mut manifest);
@@ -1958,28 +2211,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::CopySelected => {
let count = selection_state.copy_to_clipboard(notebook.current_page());
if count > 0 {
clipboard.note_internal_copy(&storage_dir);
ui_state.set_export_message(format!("{count} Objekt(e) kopiert"));
window.request_redraw();
}
}
UiAction::PasteSelected => {
let mut next_id = notebook.next_stroke_id;
let new_ids = selection_state.paste_from_clipboard(
notebook.current_page_mut(),
&mut || {
let id = next_id;
next_id += 1;
id
},
paste(
&mut clipboard, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
notebook.next_stroke_id = next_id;
if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw();
}
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::InsertImageFile(path) => {
let center = visible_page_center(&canvas);
insert_image_file(
&path, center, &storage_dir, &mut canvas, &mut notebook, &mut manifest,
&mut autosave, &mut selection_state, &mut ui_state,
);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::RotateSelected => {
if !selection_state.is_empty()
@@ -2083,6 +2335,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw();
}
UiAction::LoadAllPages => {
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
// Blättern behandelt (Ersatzseite, Warnung beim Öffnen der Seite)
for idx in 0..notebook.pages.len() {
if !notebook.pages[idx].is_loaded {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
}
}
window.request_redraw();
}
UiAction::GoToPage(idx) => {
if idx < notebook.pages.len() {
selection_state.clear();
@@ -2133,7 +2395,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("[Storage-Lock] {msg}");
_notebook_lock = None;
autosave.set_read_only(true);
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
}
Err(e) => {
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
@@ -2394,14 +2656,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
UiAction::SetSidebarSide(side) => {
ui_settings.sidebar_side = side;
if let Some(path) = ui_settings_path.as_deref() {
if let Err(err) = ui_settings.save_to(path) {
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
}
}
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::SetPaperTheme { theme, adapt_ink } => {
canvas.set_paper_theme(theme, adapt_ink);
ui_settings.paper_theme = theme;
ui_settings.adapt_ink_colors = adapt_ink;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::SavePressureCurve(curve) => {
ui_settings.pressure_curve = curve;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
}
UiAction::ForceExit => {
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
target.exit();
@@ -2440,6 +2708,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
canvas.prepare_images(&AssetStore::new(&storage_dir));
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
+96
View File
@@ -0,0 +1,96 @@
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
//!
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
//! übersteuert die Wahl.
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
Automatic,
X11,
Wayland,
}
/// Wählt das Anzeigesystem.
///
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
/// - `is_wsl`: läuft unter WSL
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
pub fn choose_display_backend(
override_value: Option<&str>,
is_wsl: bool,
has_x11_display: bool,
) -> DisplayBackend {
match override_value
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("x11") => return DisplayBackend::X11,
Some("wayland") => return DisplayBackend::Wayland,
_ => {}
}
if is_wsl && has_x11_display {
DisplayBackend::X11
} else {
DisplayBackend::Automatic
}
}
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
pub fn is_wsl() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
/// Wahl anhand der aktuellen Umgebung.
pub fn display_backend_from_env() -> DisplayBackend {
choose_display_backend(
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
is_wsl(),
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
assert_eq!(
choose_display_backend(None, true, true),
DisplayBackend::X11
);
// WSL ohne X11-Display: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, true, false),
DisplayBackend::Automatic
);
// Natives Linux: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, false, true),
DisplayBackend::Automatic
);
// Übersteuerung
assert_eq!(
choose_display_backend(Some("wayland"), true, true),
DisplayBackend::Wayland
);
assert_eq!(
choose_display_backend(Some(" X11 "), false, true),
DisplayBackend::X11
);
assert_eq!(
choose_display_backend(Some("quatsch"), true, true),
DisplayBackend::X11
);
}
}
+60 -13
View File
@@ -115,12 +115,15 @@ impl SelectionState {
Page::selection_aabb(&strokes)
}
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
pub fn hit_test_handles(
&self,
page: &Page,
canvas: &CanvasState,
screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
if (screen_pos - rot_screen).length() <= 14.0 {
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let ppp = pixels_per_point;
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::RotateHandle {
center_page: page_center,
};
@@ -164,7 +168,7 @@ impl SelectionState {
];
for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 {
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle {
corner_idx: i,
fixed_corner_page: fixed_corners_page[i],
@@ -239,7 +243,12 @@ impl SelectionState {
if self.selected_ids.is_empty() {
return false;
}
let mut strokes = page.get_strokes(&self.selected_ids);
// Bilder behalten ihre Farben
let mut strokes: Vec<_> = page
.get_strokes(&self.selected_ids)
.into_iter()
.filter(|s| !s.is_image())
.collect();
if strokes.is_empty() {
return false;
}
@@ -273,6 +282,16 @@ impl SelectionState {
}
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
const HANDLE_HIT_RADIUS: f32 = 18.0;
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
}
pub fn render_selection_overlay(
ctx: &egui::Context,
selection: &SelectionState,
@@ -283,6 +302,8 @@ pub fn render_selection_overlay(
egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"),
));
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
let ppp = ctx.pixels_per_point();
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect {
@@ -290,8 +311,8 @@ pub fn render_selection_overlay(
current_page,
} = selection.drag_mode
{
let p1 = canvas.page_to_screen(start_page);
let p2 = canvas.page_to_screen(current_page);
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]);
@@ -303,14 +324,14 @@ pub fn render_selection_overlay(
painter.rect_filled(
rect,
egui::Rounding::same(4.0_f32),
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
);
painter.rect_stroke(
rect,
egui::Rounding::same(4.0_f32),
egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
),
);
return;
@@ -321,8 +342,8 @@ pub fn render_selection_overlay(
return;
};
let p_min = canvas.page_to_screen(aabb.min);
let p_max = canvas.page_to_screen(aabb.max);
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
@@ -338,7 +359,7 @@ pub fn render_selection_overlay(
// Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
painter.line_segment(
[top_center, rot_handle],
@@ -483,4 +504,30 @@ mod tests {
let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
}
#[test]
fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
let undo_depth = page.history.undo_stack.len();
let mut sel = SelectionState::new();
sel.select_single(1);
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.history.undo_stack.len(), undo_depth);
// Bild und Strich: nur der Strich wird umgefärbt
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
sel.select_multiple(vec![1, 2]);
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
}
}
+149 -3
View File
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::background::PaperTheme;
use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
@@ -18,11 +19,82 @@ pub enum SidebarSide {
Right,
}
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PressureCurve {
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
pub exponent: f32,
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
/// unterdrückt das Grundrauschen empfindlicher Stifte
pub threshold: f32,
}
impl PressureCurve {
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
pub fn apply(&self, raw: f32) -> f32 {
let curve = self.sanitized();
if !raw.is_finite() {
return 0.0;
}
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
normalized.powf(curve.exponent)
}
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
/// gültigen Bereich.
pub fn sanitized(&self) -> Self {
let default = Self::default();
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
if v.is_finite() {
v.clamp(*range.start(), *range.end())
} else {
fallback
}
};
Self {
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
}
}
}
impl Default for PressureCurve {
fn default() -> Self {
Self {
exponent: 1.0,
threshold: 0.0,
}
}
}
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub sidebar_side: SidebarSide,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
pub adapt_ink_colors: bool,
/// Druckkennlinie des Stifts
pub pressure_curve: PressureCurve,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
sidebar_side: SidebarSide::default(),
paper_theme: PaperTheme::default(),
adapt_ink_colors: true,
pressure_curve: PressureCurve::default(),
}
}
}
impl UiSettings {
@@ -37,7 +109,7 @@ impl UiSettings {
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self {
match std::fs::read_to_string(path) {
let mut settings: Self = match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
@@ -46,7 +118,9 @@ impl UiSettings {
Self::default()
}),
Err(_) => Self::default(),
}
};
settings.pressure_curve = settings.pressure_curve.sanitized();
settings
}
/// Speichert die Einstellungen atomar.
@@ -69,6 +143,12 @@ mod tests {
let settings = UiSettings {
sidebar_side: SidebarSide::Right,
paper_theme: PaperTheme::Black,
adapt_ink_colors: false,
pressure_curve: PressureCurve {
exponent: 1.8,
threshold: 0.1,
},
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
@@ -81,4 +161,70 @@ mod tests {
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
}
#[test]
fn test_old_settings_file_keeps_new_defaults() {
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ui.json");
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.sidebar_side, SidebarSide::Right);
assert_eq!(settings.paper_theme, PaperTheme::White);
assert!(settings.adapt_ink_colors);
assert_eq!(settings.pressure_curve, PressureCurve::default());
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
std::fs::write(
&path,
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
)
.unwrap();
let curve = UiSettings::load_from(&path).pressure_curve;
assert_eq!(curve.exponent, 3.0);
assert_eq!(curve.threshold, 0.0);
}
#[test]
fn test_pressure_curve() {
// Standard: unverändert
let linear = PressureCurve::default();
for p in [0.0, 0.25, 0.5, 1.0] {
assert!((linear.apply(p) - p).abs() < 1e-6);
}
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
let soft = PressureCurve {
exponent: 0.5,
threshold: 0.0,
};
let hard = PressureCurve {
exponent: 2.0,
threshold: 0.0,
};
assert!(soft.apply(0.5) > 0.6);
assert!(hard.apply(0.5) < 0.3);
// Endpunkte bleiben fest
for curve in [soft, hard] {
assert_eq!(curve.apply(0.0), 0.0);
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
}
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
let gated = PressureCurve {
exponent: 1.0,
threshold: 0.2,
};
assert_eq!(gated.apply(0.15), 0.0);
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
// Monoton steigend
let mut last = -1.0;
for i in 0..=100 {
let v = hard.apply(i as f32 / 100.0);
assert!(v >= last);
last = v;
}
// Ungültige Eingaben ergeben nie NaN
assert_eq!(linear.apply(f32::NAN), 0.0);
assert_eq!(linear.apply(7.0), 1.0);
}
}
+45
View File
@@ -14,6 +14,8 @@ struct VertexOutput {
struct CameraUniforms {
transform_scale: vec2<f32>,
transform_offset: vec2<f32>,
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
ink_params: vec4<f32>,
};
@group(0) @binding(0)
@@ -32,3 +34,46 @@ fn vs_main(in: VertexInput) -> VertexOutput {
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color;
}
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
if (camera.ink_params.x < 0.5) {
return in.color;
}
let max_c = max(max(in.color.r, in.color.g), in.color.b);
let min_c = min(min(in.color.r, in.color.g), in.color.b);
let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
struct ImageVertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
};
struct ImageVertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(1) @binding(0)
var image_texture: texture_2d<f32>;
@group(1) @binding(1)
var image_sampler: sampler;
@vertex
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
var out: ImageVertexOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.uv = in.uv;
return out;
}
@fragment
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
return textureSample(image_texture, image_sampler, in.uv);
}
+202 -5
View File
@@ -70,6 +70,9 @@ pub struct StrokeData {
pub width: f32,
#[serde(default = "default_finalized")]
pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
}
impl From<&Stroke> for StrokeData {
@@ -82,6 +85,7 @@ impl From<&Stroke> for StrokeData {
tool: stroke.tool,
width: stroke.width,
finalized: stroke.finalized,
image: stroke.image.clone(),
}
}
}
@@ -96,6 +100,17 @@ impl StrokeData {
.copied()
.map(StylusSample::from)
.collect();
if let Some(asset) = &self.image {
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
let mut stroke = Stroke::new_image(
self.id,
asset.clone(),
[a.position, b.position, c.position, d.position],
);
stroke.color = self.color;
return stroke;
}
}
let width = if self.width > 0.0 {
self.width
} else if self.shape.is_some() {
@@ -117,15 +132,26 @@ impl StrokeData {
};
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
stroke.shape = self.shape.clone();
stroke.tool = self.tool;
stroke.tool = crate::document::StrokeTool::resolve_stored(
self.tool,
self.color,
self.shape.is_some(),
);
stroke.width = width;
stroke.finalized = self.finalized;
stroke
}
}
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized).
pub const CURRENT_FORMAT_VERSION: u32 = 2;
/// Aktuelle Dateiformat-Version von ink-notes.
/// - Version 2: Stroke-Treue mit tool, width, finalized
/// - Version 3: neue Formen (Ellipse, Pfeil). Ältere App-Versionen kennen sie nicht und würden
/// betroffene Seiten für beschädigt halten; mit Version 3 lehnen sie das Notizbuch stattdessen
/// sauber als „neuere Version“ ab.
/// - Version 4: eingefügte Bilder (`image` am Strich, Ordner `assets/`). Ältere Versionen würden
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
/// verlieren.
pub const CURRENT_FORMAT_VERSION: u32 = 4;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
@@ -697,6 +723,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
);
notebook.load_warnings = load_warnings;
// Die zuletzt geöffnete Seite sofort laden: Sie ist nach dem Start die aktuelle Seite,
// und ein leerer Platzhalter würde beim Weiterschreiben ihren Inhalt überschreiben.
let current = notebook.current_page_index;
if !notebook.pages[current].is_loaded {
if let Err(e) = ensure_page_loaded(notebook_dir, &mut notebook, current) {
eprintln!("[Storage] Aktuelle Seite {current} konnte nicht geladen werden: {e}");
}
}
Ok((notebook, manifest))
}
@@ -2160,11 +2195,173 @@ mod tests {
let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err());
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_err());
load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_ok());
}
#[test]
fn test_restart_on_later_page_keeps_its_content() {
// Regression: Beim Start wurde nur Seite 1 geladen; war zuletzt eine spätere Seite
// offen, war sie ein leerer Platzhalter, und Weiterschreiben überschrieb ihren Inhalt.
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neustart").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
notebook.current_page_index = 1;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut loaded, mut loaded_manifest) = load_notebook(&dir).unwrap();
assert_eq!(loaded.current_page_index, 1);
assert!(
loaded.current_page().is_loaded,
"aktuelle Seite muss geladen sein"
);
assert_eq!(loaded.current_page().strokes.len(), 1);
// Weiterschreiben und speichern darf den bisherigen Inhalt nicht verlieren
let extra = page_with_one_stroke(99, PageBackground::Lined).strokes[0].clone();
loaded.current_page_mut().strokes.push(extra);
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
autosave.mark_dirty(&loaded, &mut loaded_manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
let (reloaded, _) = load_notebook(&dir).unwrap();
assert_eq!(reloaded.current_page().strokes.len(), 2);
}
#[test]
fn test_autosave_never_writes_unloaded_placeholder() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Platzhalter").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let page_file = dir.join(format!("page_{}.json", notebook.pages[1].id));
let before = fs::read(&page_file).unwrap();
// Platzhalter (nicht geladen) als aktuelle Seite markieren und speichern
let placeholder = Page::new_unloaded(notebook.pages[1].id, PageBackground::Lined);
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
autosave.mark_page_dirty(&placeholder, &manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
assert_eq!(
fs::read(&page_file).unwrap(),
before,
"Seitendatei wurde überschrieben"
);
}
#[test]
fn test_new_shapes_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neue Formen").unwrap();
let shapes = [
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(500.0, 600.0),
radius_x: 250.0,
radius_y: 120.0,
rotation: 0.3,
},
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(100.0, 100.0),
end: glam::Vec2::new(700.0, 300.0),
double: true,
},
];
for (i, shape) in shapes.iter().enumerate() {
notebook
.current_page_mut()
.strokes
.push(crate::geometry::shape_to_stroke(
shape.clone(),
i as u64 + 1,
[0.0, 0.0, 0.0, 1.0],
5.0,
));
}
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(
load_manifest(&dir).unwrap().format_version,
CURRENT_FORMAT_VERSION
);
let (reloaded, _) = load_notebook(&dir).unwrap();
let loaded: Vec<_> = reloaded
.current_page()
.strokes
.iter()
.map(|s| s.shape.clone())
.collect();
assert_eq!(loaded, shapes.iter().cloned().map(Some).collect::<Vec<_>>());
}
#[test]
fn test_stroke_tool_of_old_files_is_recovered() {
// Bis v0.3.7 wurde jeder Strich als "pen" gespeichert
let json = r#"{"id": 1, "color": [1.0, 0.95, 0.1, 0.35], "tool": "pen",
"samples": [{"x": 10.0, "y": 10.0, "p": 0.5, "t": 0.0},
{"x": 90.0, "y": 10.0, "p": 0.5, "t": 0.1}]}"#;
let data: StrokeData = serde_json::from_str(json).unwrap();
let highlighter = data.to_stroke();
assert_eq!(highlighter.tool, crate::document::StrokeTool::Highlighter);
let mut pen = data.clone();
pen.color = [0.12, 0.12, 0.14, 1.0];
assert_eq!(pen.to_stroke().tool, crate::document::StrokeTool::Pen);
let mut shape = pen.clone();
shape.shape = Some(crate::geometry::RecognizedShape::Line {
start: glam::Vec2::new(10.0, 10.0),
end: glam::Vec2::new(90.0, 10.0),
});
assert_eq!(shape.to_stroke().tool, crate::document::StrokeTool::Shape);
// Ausdrücklich gespeicherte Werkzeuge bleiben unverändert
let mut explicit = data.clone();
explicit.tool = crate::document::StrokeTool::Shape;
assert_eq!(
explicit.to_stroke().tool,
crate::document::StrokeTool::Shape
);
}
#[test]
fn test_image_objects_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Bild").unwrap();
let assets = crate::assets::AssetStore::new(&dir);
let imported = assets.import_rgba(4, 2, vec![200; 32]).unwrap();
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(500.0, 120.0),
glam::Vec2::new(480.0, 320.0),
glam::Vec2::new(80.0, 300.0),
];
notebook.current_page_mut().strokes.push(Stroke::new_image(
7,
imported.asset.clone(),
corners,
));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(load_manifest(&dir).unwrap().format_version, 4);
let (reloaded, _) = load_notebook(&dir).unwrap();
let image = &reloaded.current_page().strokes[0];
assert_eq!(image.image.as_deref(), Some(imported.asset.as_str()));
assert_eq!(image.image_corners(), Some(corners));
assert!(image.vertices.is_empty());
// Bilddatei liegt im Notizbuchordner
assert!(dir.join("assets").join(&imported.asset).exists());
}
}
+1356 -658
View File
File diff suppressed because it is too large Load Diff