Compare commits

...
33 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 6caf3f7476 feat: Seitenformat folgt dem PDF (Querformat für Folien)
Seiten haben jetzt eine eigene Größe. Beim PDF-Import bleiben Hochformat-
Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine
Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren
Streifen darunter (Issue #4). Eingefügte und angehängte Seiten übernehmen
das Format der aktuellen Seite.

Anzeige: Maßstab aus dem Rahmen aller Seiten, die durchgehende Ansicht
stapelt verschieden große Seiten zentriert untereinander. Export (PDF,
PNG, SVG) in der jeweiligen Seitengröße. Dateiformat-Version 8 (Größe in
Seite und Manifest; A4-Seiten speichern keine Größe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:20:44 +02:00
haraldandClaude Opus 5.5 9fde59798f feat: Neue Seite direkt nach der aktuellen einfügen
„Neue Seite einfügen“ (☰-Menü und Seitenauswahl) legt die leere Seite
direkt hinter der aktuellen an und wechselt dorthin, statt sie ans Ende
zu hängen – etwa zwischen zwei Folien eines PDFs (Issue #3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:30 +02:00
haraldandClaude Opus 5.5 438106cafd fix: Auswahlwerkzeug funktioniert mit dem Stift
Bisher reagierte das Auswahlwerkzeug nur auf Maus und Finger (Issue #2).
Drücken, Ziehen und Loslassen der Auswahl sind jetzt gemeinsame Funktionen
für Maus, Finger und Stift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:01:23 +02:00
haraldandClaude Opus 5.5 392c22bc4a feat: Zoom bis 1000 %, PDF-Seiten beim Hineinzoomen schärfer
Höchster Zoom 1000 % statt 500 % (Issue #1). Importierte PDF-Seiten werden
mit doppelter Auflösung nachgerendert, sobald die Standardauflösung
sichtbar vergrößert würde; bis dahin bleibt sie als Ersatz sichtbar.
Texte werden bis 8 Pixel je Einheit gerastert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:27 +02:00
haraldandClaude Opus 5.5 6002eee9e8 chore(release): bump version to 0.10.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:43:56 +02:00
haraldandClaude Opus 5.5 2eab394257 feat: Text-Werkzeug
Textfelder per Tastatur (T): normale oder Code-Schrift, 8–36 pt, Farben
der Stiftpalette, Umbruch am rechten Feldrand. Textfelder sind Objekte
mit vier Ecken wie Bilder (verschieben, drehen, skalieren) und werden als
Textur gerastert; im PDF-Export echter, durchsuchbarer Text mit
eingebetteter Schrift. Kopierter Text wird mit Strg+V zum Textfeld.
Dateiformat-Version 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:40:45 +02:00
haraldandClaude Opus 5.5 6a51ca3aa4 chore(release): bump version to 0.9.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 5m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:52:01 +02:00
haraldandClaude Opus 5.5 6f2b414bb8 feat: durchgehendes Scrollen
Seiten hängen untereinander (8 mm Abstand) und werden mit Mausrad, Finger
oder Touchpad fließend gescrollt; Strg+Mausrad zoomt. Sichtbare Nachbarseiten
werden bei Bedarf geladen und mit eigener Kamera gezeichnet. Die Seite in der
Fenstermitte wird nach dem Scrollen zur aktuellen; Zeichnen, Radieren und
Auswählen auf einer Nachbarseite wechseln zuerst dorthin. Wer im unteren
Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite.
Scrollen bleibt auf das Notizbuch begrenzt. Abschaltbar im ☰-Menü (Ansicht).

Nie gespeicherte, angehängte Seiten laden jetzt als leere Seite statt als
schreibgeschützte Fehlerseite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:36:19 +02:00
haraldandClaude Opus 5.5 9dec688b84 chore(release): bump version to 0.8.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m53s
Virtuelles Lineal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:52:19 +02:00
haraldandClaude Opus 5.5 7e05b33ee3 feat: virtuelles Lineal
- Lineal ein-/ausblenden über die Werkzeugleiste oder Taste L
  (26 cm, mm-/cm-Skala an beiden Kanten, Winkelanzeige)
- Fläche ziehen verschiebt, Griff ⟳ dreht; 15°-Schritte rasten ein
- Striche, die bis 6 mm neben einer Längskante beginnen, folgen bis zum
  Abheben exakt der Kante (Stift, Textmarker, Formen); die Tinte liegt
  um die halbe Strichbreite neben dem Lineal
- Lineal bleibt beim Blättern an seiner Stelle; nur seine Fläche und
  der Griff nehmen Eingaben an, an den Kanten wird gezeichnet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:46:08 +02:00
haraldandClaude Opus 5.5 587632b173 chore(release): bump version to 0.7.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m16s
Teilstrich-Radierer, Seiten-Export als PNG/SVG, Auswahl als Bild, Seitentitel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:32:56 +02:00
haraldandClaude Opus 5.5 701fa03f33 feat: Teilstrich-Radierer, Seiten-Export (PNG/SVG), Auswahl als Bild, Seitentitel
- Radierer-Modus „✂ Teilstrich“: entfernt nur den überstrichenen Teil, der Rest
  bleibt in Stücken; ein Radierzug ist ein Undo-Schritt, Formen werden ganz radiert
- ☰ → „Seite als PNG / PNG transparent / SVG“ nach ~/Documents (über den
  PDF-Export gerendert: Bilder und importierte PDF-Seiten inklusive)
- Strg+C legt die Auswahl zusätzlich als Bild in die Systemzwischenablage
  (für Chat, Mail, Office); in ink-notes fügt Strg+V weiter die Objekte ein
- Seitentitel in der Seitenübersicht, Ansicht „Inhalt“ als Inhaltsverzeichnis,
  Lesezeichen-Ecke an benannten Seiten, Titel im Fenstertitel und als
  PDF-Lesezeichen (Umlaute als UTF-16)
- Dateiformat-Version 6 (Seitentitel)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:19:13 +02:00
haraldandClaude Opus 5.5 91a24b9f82 chore(release): bump version to 0.6.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m45s
PDF importieren und annotiert exportieren (v0.6.0 scheiterte in der CI an Clippy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:29:30 +02:00
haraldandClaude Opus 5.5 64e13e1155 fix(ci): Clippy-Hinweise seit Anhebung der Mindest-Rust-Version
Mit rust-version 1.92 meldet Clippy is_none_or statt map_or(true, …) und
as_chunks statt chunks_exact mit fester Größe; die CI bricht bei Warnungen ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:30 +02:00
haraldandClaude Opus 5.5 8a0477740e chore(release): bump version to 0.6.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 3m32s
PDF importieren und annotiert exportieren, Dateiformat-Version 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:07:25 +02:00
haraldandClaude Opus 5.5 66679e51ae feat: PDF importieren und annotiert exportieren
- ☰ → „PDF importieren …“ oder PDF ins Fenster ziehen: neues Notizbuch
  mit einer Seite je PDF-Seite, die Originalseite als Hintergrund
- Querformat-Folien liegen oben auf dem A4-Blatt, darunter ist Platz für
  Notizen; gedrehte Seiten (/Rotate) erscheinen wie im PDF-Betrachter
- Anzeige mit hayro (reines Rust) im Hintergrund-Thread, nächste Seite
  wird vorab gerendert
- PDF-Export legt die Originalseiten als Vektorgrafik (Form-XObject via
  lopdf) unter Bilder und Tinte; nicht übernehmbare Seiten (z. B.
  verschlüsselt) werden als Bild mit 200 dpi eingebettet
- Dateiformat-Version 5 (pdf_background an der Seite)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:47:49 +02:00
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
28 changed files with 11549 additions and 913 deletions
Generated
+712 -19
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,12 +1,12 @@
[package]
name = "ink-notes"
version = "0.3.2"
version = "0.10.0"
edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT"
readme = "README.md"
rust-version = "1.75"
rust-version = "1.92"
[dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -39,8 +39,17 @@ 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"
hayro = "0.8"
hayro-svg = "0.8"
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
ab_glyph = "0.2"
[profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -53,3 +62,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"] }
+8 -3
View File
@@ -18,10 +18,12 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
- **Text (`T`):** Ins Blatt tippen und mit der Tastatur schreiben – für Überschriften, Code-Schnipsel oder kopierten Text. Normale Schrift oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8 bis 36 pt, Farben der Stiftpalette; Zeilen werden am rechten Rand des Textfelds umbrochen. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren; im PDF-Export ist der Text echt (durchsuchbar und kopierbar). Aus anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
### 🔍 Stufenloser Zoom & Pan (Kamera)
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via Mausrad, Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
- **Durchgehendes Scrollen:** Seiten hängen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehängt. Abschaltbar im ☰-Menü unter „Ansicht“ (dann Seite für Seite blättern).
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 1000 %** (importierte PDF-Seiten werden beim Hineinzoomen schärfer nachgerendert) zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
@@ -52,8 +54,9 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### 📄 Seiten & Hintergründe
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
- **Seitenformat aus dem PDF:** Beim PDF-Import bleiben Hochformat-Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren Streifen darunter. Eingefügte Seiten übernehmen das Format der aktuellen Seite.
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anhängen oder direkt nach der aktuellen Seite einfügen (☰-Menü oder Seitenauswahl, z. B. zwischen zwei PDF-Folien), Seitensprung-Menü.
### 💾 Autosave & Persistenz
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
@@ -219,10 +222,12 @@ cargo run --release
| :--- | :--- |
| **Zeichnen** | Linke Maustaste / Stiftspitze |
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
| **Zoom zentriert auf Cursor** | Mausrad / Touchpad-Pinch |
| **Scrollen (durchgehende Ansicht)** | Mausrad (`Umschalt` = waagerecht) / ein Finger |
| **Zoom zentriert auf Cursor** | `Strg + Mausrad` / Touchpad-Pinch (Einzelseiten-Ansicht: Mausrad) |
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
| **Auswahlwerkzeug (Lasso)** | `V` |
| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
| **Auswahl um 90° drehen** | `R` |
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
| **Auswahl löschen** | `Entf` oder `Backspace` |
+17 -2
View File
@@ -3,13 +3,28 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
- **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
- **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 als Kachelraster, größere Bedienelemente in allen Popovern
- **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
+366
View File
@@ -0,0 +1,366 @@
//! 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`, `.jpg` oder `.pdf`. 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" | "pdf")
}
/// 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 eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein).
pub fn import_pdf(&self, bytes: &[u8]) -> Result<String, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.pdf", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(asset)
}
/// Ü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);
+215 -42
View File
@@ -1,7 +1,9 @@
//! 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};
use crate::document::PageBackground;
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
pub const LINE_THICKNESS: f32 = 1.5;
@@ -13,43 +15,129 @@ 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 ein Blatt der Größe `size`.
pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex> {
let (page_width, page_height) = (size.x, size.y);
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` auf
/// einem Blatt der Größe `size`.
pub fn generate_background_mesh(
background: PageBackground,
color: [f32; 4],
size: glam::Vec2,
) -> Vec<InkVertex> {
let (page_width, page_height) = (size.x, size.y);
match background {
PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => {
@@ -58,15 +146,29 @@ 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);
while x < page_width {
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);
while y < page_height {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
page_width,
y + half_t,
);
y += GRID_5MM_SPACING;
}
@@ -81,8 +183,15 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
let margin_bottom = 150.0;
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);
while y <= page_height - margin_bottom {
push_quad(
&mut vertices,
color,
0.0,
y - half_t,
page_width,
y + half_t,
);
y += LINED_SPACING;
}
@@ -93,11 +202,12 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
let half_d = DOT_SIZE / 2.0;
let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT {
while y < page_height {
let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH {
while x < page_width {
push_quad(
&mut vertices,
color,
x - half_d,
y - half_d,
x + half_d,
@@ -117,20 +227,29 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
mod tests {
use super::*;
use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH};
#[test]
fn test_paper_quad_generation() {
let quad = generate_paper_quad();
let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE);
assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
let slide = generate_paper_quad(
PaperTheme::White.paper_color(),
glam::Vec2::new(2970.0, 1671.0),
);
assert_eq!(slide[5].position, [2970.0, 1671.0]);
}
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, A4_SIZE);
assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm);
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
assert!(!grid.is_empty());
assert_eq!(
grid.len() % 6,
@@ -138,12 +257,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, A4_SIZE);
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, A4_SIZE);
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, A4_SIZE);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
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:?}");
}
}
}
+612 -51
View File
@@ -6,15 +6,17 @@
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
use std::collections::HashMap;
use std::ops::Range;
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};
use crate::viewport::PageStack;
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
#[repr(C)]
@@ -58,6 +60,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 +91,75 @@ 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())
}
/// Erkennt, ob die GPU-Daten einer Nachbarseite noch zur Seite passen. Änderungen an der
/// aktuellen Seite verwerfen deren Eintrag ohnehin (siehe [`CanvasState::sync_page`]).
#[derive(Debug, Clone, PartialEq)]
struct NeighborFingerprint {
page_id: u64,
background: PageBackground,
size: [u32; 2],
paper_theme: PaperTheme,
vertex_count: usize,
stroke_count: usize,
last_stroke_id: Option<u64>,
}
impl NeighborFingerprint {
fn of(page: &Page, paper_theme: PaperTheme) -> Self {
Self {
page_id: page.id,
background: page.background,
size: page.size.to_array().map(f32::to_bits),
paper_theme,
vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(),
last_stroke_id: page.strokes.last().map(|s| s.id),
}
}
}
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
struct NeighborPage {
fingerprint: NeighborFingerprint,
/// Ecke oben links in Koordinaten der aktuellen Seite
offset: glam::Vec2,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
background_buffer: Option<wgpu::Buffer>,
background_vertex_count: u32,
vertex_buffer: Option<wgpu::Buffer>,
stroke_ranges: Vec<Range<u32>>,
}
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 +179,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 +186,20 @@ 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,
/// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen
page_size: glam::Vec2,
fit_box: glam::Vec2,
/// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung)
text_specs: HashMap<String, TextSpec>,
/// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung
/// zeigt es)
hidden_text: Option<u64>,
/// Auflösung der Texttexturen in Pixeln je Seiteneinheit
text_resolution: f32,
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
pub live_vertex_buffer: wgpu::Buffer,
@@ -133,6 +217,18 @@ 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,
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite
neighbors: Vec<NeighborPage>,
camera_layout: wgpu::BindGroupLayout,
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
pub ruler: Option<crate::ruler::Ruler>,
/// Kante, an der der aktuelle Live-Strich eingerastet ist
ruler_lock: Option<crate::ruler::EdgeLock>,
// egui UI-Renderer
pub egui_renderer: egui_wgpu::Renderer,
}
@@ -149,23 +245,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 +299,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 +307,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 +345,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 +382,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 +436,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(), crate::document::A4_SIZE);
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 +447,11 @@ 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(),
crate::document::A4_SIZE,
);
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 +475,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 +502,18 @@ 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,
page_size: crate::document::A4_SIZE,
fit_box: crate::document::A4_SIZE,
text_specs: HashMap::new(),
hidden_text: None,
text_resolution: 1.0,
live_vertex_buffer,
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
live_vertex_count: 0,
@@ -404,6 +530,11 @@ impl CanvasState {
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128),
image_layer,
neighbors: Vec::new(),
camera_layout: bind_group_layout,
ruler: None,
ruler_lock: None,
egui_renderer,
camera: crate::viewport::ViewportState::new(),
})
@@ -411,16 +542,40 @@ impl CanvasState {
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
pub fn update_uniforms(&mut self) {
let (scale, offset) = self.camera.ndc_transform(&self.viewport);
let camera_uniforms = CameraUniforms {
transform_scale: scale,
transform_offset: offset,
};
let camera_uniforms = self.camera_uniforms(glam::Vec2::ZERO);
self.queue.write_buffer(
&self.camera_buffer,
0,
bytemuck::cast_slice(&[camera_uniforms]),
);
for neighbor in &self.neighbors {
self.queue.write_buffer(
&neighbor.camera_buffer,
0,
bytemuck::cast_slice(&[self.camera_uniforms(neighbor.offset)]),
);
}
}
/// Kamera für eine Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite).
fn camera_uniforms(&self, origin: glam::Vec2) -> CameraUniforms {
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
offset[0] += origin.x * scale[0];
offset[1] += origin.y * scale[1];
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,
],
}
}
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
@@ -435,13 +590,22 @@ impl CanvasState {
true
}
/// Rahmen, in den alle Seiten des Notizbuchs passen (bestimmt den Maßstab bei 100 %).
pub fn set_fit_box(&mut self, fit_box: glam::Vec2) {
if self.fit_box != fit_box {
self.fit_box = fit_box;
self.recalculate_viewport();
}
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets(
self.viewport = PageViewport::calculate_for(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
self.ui_inset_left,
self.ui_inset_right,
(self.ui_inset_left, self.ui_inset_right),
self.fit_box,
self.page_size,
);
self.update_uniforms();
}
@@ -452,6 +616,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();
}
@@ -494,6 +675,63 @@ impl CanvasState {
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
}
/// Durchgehende Ansicht: Seite unter dem Bildschirmpunkt, `None` neben den Seiten und
/// zwischen zwei Seiten.
pub fn page_at_screen(&self, stack: &PageStack, screen_x: f32, screen_y: f32) -> Option<usize> {
let p = self
.camera
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
let index = stack.page_at(p.y)?;
let x = p.x - stack.origins[index].x;
(0.0..=stack.sizes[index].x).contains(&x).then_some(index)
}
/// Durchgehende Ansicht: Seite auf Höhe der Fenstermitte.
pub fn page_at_view_center(&self, stack: &PageStack) -> Option<usize> {
let p = self.camera.screen_to_page_unclamped(
0.0,
self.size.height as f32 / 2.0,
&self.viewport,
);
stack.page_at(p.y)
}
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite.
pub fn visible_neighbors(&self, stack: &PageStack) -> Vec<usize> {
let top = self
.camera
.screen_to_page_unclamped(0.0, 0.0, &self.viewport);
let bottom =
self.camera
.screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport);
stack.visible_neighbors(top.y, bottom.y)
}
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seiten), damit
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
pub fn clamp_scroll(&mut self, stack: &PageStack) -> bool {
let center = [
self.viewport.page_x + self.viewport.page_width / 2.0,
self.size.height as f32 / 2.0,
];
let changed = self
.camera
.clamp_continuous_center(center, stack, &self.viewport);
if changed {
self.update_uniforms();
}
changed
}
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
/// Seite mit [`Self::sync_page`] zu setzen.
pub fn shift_reference_page(&mut self, origin: glam::Vec2) {
self.camera.shift_reference_page(origin);
self.update_uniforms();
}
/// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold).
pub fn set_live_shape_mesh(&mut self, samples: Vec<StylusSample>, vertices: Vec<InkVertex>) {
self.live_samples = samples;
@@ -532,9 +770,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.page_size)),
);
self.upload_background_mesh();
}
self.update_uniforms();
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -543,7 +791,16 @@ 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.page_size,
);
self.background_vertex_count = mesh.len() as u32;
if mesh.len() > self.background_vertex_capacity {
@@ -565,7 +822,24 @@ impl CanvasState {
/// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite.
pub fn sync_page(&mut self, page: &mut Page) {
page.rebuild_cache_if_dirty();
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
self.neighbors.retain(|n| n.fingerprint.page_id != page.id);
if self.page_size != page.size {
self.page_size = page.size;
self.queue.write_buffer(
&self.paper_buffer,
0,
bytemuck::cast_slice(&generate_paper_quad(
self.paper_theme.paper_color(),
self.page_size,
)),
);
self.upload_background_mesh();
self.recalculate_viewport();
}
self.set_background(page.background);
let quads = self.page_image_quads(page, self.hidden_text);
self.image_layer.set_quads(quads);
self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone();
@@ -592,6 +866,164 @@ impl CanvasState {
);
}
/// Durchgehende Ansicht: übernimmt die sichtbaren Nachbarseiten mit ihrem Versatz zur
/// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein
/// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen.
/// Eine leere Liste schaltet die Nachbarseiten ab.
pub fn set_neighbor_pages(&mut self, pages: &[(glam::Vec2, &Page)]) {
let mut old = std::mem::take(&mut self.neighbors);
let mut image_groups = Vec::with_capacity(pages.len());
for &(offset, page) in pages {
let fingerprint = NeighborFingerprint::of(page, self.paper_theme);
let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) {
Some(i) => old.swap_remove(i),
None => self.upload_neighbor(page, fingerprint),
};
neighbor.offset = offset;
self.queue.write_buffer(
&neighbor.camera_buffer,
0,
bytemuck::cast_slice(&[self.camera_uniforms(offset)]),
);
self.neighbors.push(neighbor);
image_groups.push(self.page_image_quads(page, None));
}
self.image_layer.set_neighbor_quads(image_groups);
}
/// Lädt Raster und Striche einer Nachbarseite auf die GPU.
fn upload_neighbor(&self, page: &Page, fingerprint: NeighborFingerprint) -> NeighborPage {
let camera_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Neighbor Camera Uniform Buffer"),
size: std::mem::size_of::<CameraUniforms>() as wgpu::BufferAddress,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let camera_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Neighbor Camera Bind Group"),
layout: &self.camera_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
(!vertices.is_empty()).then(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: std::mem::size_of_val(vertices) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.queue
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
buffer
})
};
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
background.extend(generate_background_mesh(
page.background,
self.paper_theme.pattern_color(),
page.size,
));
NeighborPage {
fingerprint,
offset: glam::Vec2::ZERO,
camera_buffer,
camera_bind_group,
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
background_vertex_count: background.len() as u32,
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
stroke_ranges: page.stroke_ranges.clone(),
}
}
/// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen.
/// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts.
pub fn prepare_images(
&mut self,
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
) {
// Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen)
let resolution = text_resolution(self.viewport.scale * self.camera.zoom);
if resolution != self.text_resolution {
self.text_resolution = resolution;
self.image_layer
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX));
}
// Importierte PDF-Seiten schärfer rendern, sobald die Standardauflösung vergrößert würde
let page_long_edge = self.page_size.max_element() * self.viewport.scale * self.camera.zoom;
self.image_layer.set_pdf_pixels(
(page_long_edge > crate::pdf_doc::DISPLAY_RENDER_PIXELS as f32 * 1.25)
.then_some(crate::pdf_doc::ZOOMED_RENDER_PIXELS),
);
let specs = &self.text_specs;
self.image_layer
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) {
Some(spec) => crate::image_layer::TextureSource::Ready(crate::text::rasterize(
&spec.text,
spec.color,
resolution * spec.scale,
)),
None => load(key),
});
let layer = &self.image_layer;
self.text_specs.retain(|key, _| layer.uses(key));
}
/// Blendet das gerade bearbeitete Textfeld aus (wirksam ab dem nächsten `sync_page`).
pub fn set_hidden_text(&mut self, id: Option<u64>) {
self.hidden_text = id;
}
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten
/// Bilder und Textfelder (außer `hidden`) in ihrer Reihenfolge.
fn page_image_quads(
&mut self,
page: &Page,
hidden: Option<u64>,
) -> Vec<(String, [glam::Vec2; 4])> {
let adapt = self.adapt_ink && self.paper_theme.is_dark();
let mut quads: Vec<_> = page
.pdf_background
.iter()
.map(|pdf| (pdf.key(), pdf.corners(page.size)))
.collect();
for stroke in &page.strokes {
let Some(corners) = stroke.object_corners() else {
continue;
};
if let Some(asset) = &stroke.image {
quads.push((asset.clone(), corners));
} else if let Some(text) = &stroke.text {
if Some(stroke.id) == hidden {
continue;
}
let color = if adapt {
crate::background::adapt_ink_color(stroke.color)
} else {
stroke.color
};
let key = text_key(text, color);
let scale = ((corners[1] - corners[0]).length() / text.wrap_width.max(text.size))
.max((corners[3] - corners[0]).length() / text.layout().height());
self.text_specs.entry(key.clone()).or_insert(TextSpec {
text: text.clone(),
color,
scale,
});
quads.push((key, corners));
}
}
quads
}
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
pub fn is_waiting_for_images(&self) -> bool {
self.image_layer.is_waiting()
}
/// 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();
@@ -600,6 +1032,13 @@ impl CanvasState {
self.live_vertices.clear();
self.live_vertex_count = 0;
// Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr
let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0;
self.ruler_lock = self
.ruler
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
// 1. Zwingend durch den 1€-Filter leiten
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
let pt = ControlPoint::new(fx, fy, fp);
@@ -623,6 +1062,7 @@ impl CanvasState {
}
// 1. Zwingend durch den 1€-Filter leiten
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
let new_pt = ControlPoint::new(fx, fy, fp);
@@ -645,6 +1085,19 @@ impl CanvasState {
self.update_live_mesh();
}
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
match self.ruler_lock {
Some(lock) => lock.project(glam::Vec2::new(x, y)).into(),
None => (x, y),
}
}
/// Ist der aktuelle Live-Strich an einer Linealkante eingerastet?
pub fn is_ruler_locked(&self) -> bool {
self.ruler_lock.is_some()
}
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
pub fn cancel_stroke(&mut self) {
self.live_samples.clear();
@@ -673,7 +1126,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 +1221,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,
},
})],
@@ -778,10 +1230,30 @@ impl CanvasState {
occlusion_query_set: None,
});
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
for (i, neighbor) in self.neighbors.iter().enumerate() {
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
render_pass.set_pipeline(&self.triangle_pipeline);
if let Some(buffer) = &neighbor.background_buffer {
render_pass.set_vertex_buffer(0, buffer.slice(..));
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
}
self.image_layer.draw(&mut render_pass, i + 1);
if let Some(buffer) = &neighbor.vertex_buffer {
render_pass.set_pipeline(&self.strip_pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for range in &neighbor.stroke_ranges {
if range.end > range.start {
render_pass.draw(range.clone(), 0..1);
}
}
}
}
// 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 +1264,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, 0);
// 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 +1279,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);
@@ -854,10 +1329,63 @@ impl CanvasState {
}
}
/// Zu rasterndes Textfeld.
struct TextSpec {
text: crate::text::TextBox,
/// Angezeigte (ggf. aufgehellte) Farbe
color: [f32; 4],
/// Vergrößerung des Felds durch Skalieren der Auswahl
scale: f32,
}
/// Schlüssel der Texttexturen beginnen hiermit.
const TEXT_KEY_PREFIX: &str = "text:";
/// Texturschlüssel eines Textfelds: ändert sich mit Inhalt, Satz und Farbe.
fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.content.hash(&mut hasher);
text.font.hash(&mut hasher);
for v in [text.size, text.wrap_width].into_iter().chain(color) {
v.to_bits().hash(&mut hasher);
}
format!("{TEXT_KEY_PREFIX}{:016x}", hasher.finish())
}
/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit:
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 8). So wird nicht bei jedem Zoomschritt neu
/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
/// verkleinert wirkt die dünne Schrift blass.
fn text_resolution(screen_px_per_unit: f32) -> f32 {
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_resolution_is_quantized_and_never_below_screen() {
for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] {
let r = text_resolution(px);
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}");
}
assert_eq!(text_resolution(0.27), text_resolution(0.28));
assert_eq!(text_resolution(100.0), 8.0);
}
#[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 +1411,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]
+996 -53
View File
File diff suppressed because it is too large Load Diff
+841 -108
View File
File diff suppressed because it is too large Load Diff
+248
View File
@@ -0,0 +1,248 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
//!
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
//! 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,
}
/// Welche Dateien die Auswahl anbietet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerKind {
/// Bilder zum Einfügen auf der aktuellen Seite
Image,
/// PDF zum Importieren als neues Notizbuch
Pdf,
}
impl PickerKind {
/// Passt die Datei zu dieser Auswahl?
pub fn accepts(self, path: &Path) -> bool {
match self {
PickerKind::Image => is_supported_image_path(path),
PickerKind::Pdf => is_pdf_path(path),
}
}
}
/// Ist die Datei dem Namen nach ein PDF?
pub fn is_pdf_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
}
/// Zustand der Dateiauswahl.
#[derive(Debug, Clone, PartialEq)]
pub struct FilePicker {
pub kind: PickerKind,
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 (Bilder) bzw. Downloads (PDF),
/// sonst im Home-Ordner.
pub fn open(kind: PickerKind, dir: Option<&Path>) -> Self {
let start = dir
.filter(|d| d.is_dir())
.map(Path::to_path_buf)
.or_else(|| {
let preferred = match kind {
PickerKind::Image => "🖼 Bilder",
PickerKind::Pdf => "⬇ Downloads",
};
let all = shortcuts();
all.iter()
.find(|(label, _)| *label == preferred)
.or_else(|| all.last())
.map(|(_, p)| p.clone())
})
.unwrap_or_else(|| PathBuf::from("/"));
let mut picker = Self {
kind,
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, self.kind) {
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 passende Dateien in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
pub fn list_dir(dir: &Path, kind: PickerKind) -> 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 || kind.accepts(&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",
"Skript.PDF",
] {
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 = |kind| -> Vec<String> {
list_dir(dir.path(), kind)
.unwrap()
.into_iter()
.map(|e| e.name)
.collect()
};
assert_eq!(
names(PickerKind::Image),
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
);
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
}
#[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(PickerKind::Image, 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
+53
View File
@@ -18,6 +18,13 @@ pub enum HistoryAction {
before: Vec<Stroke>,
after: Vec<Stroke>,
},
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes {
removed: Vec<Stroke>,
added: Vec<Stroke>,
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
at: std::time::Instant,
},
}
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
@@ -41,6 +48,40 @@ impl HistoryStack {
self.redo_stack.clear();
}
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
/// wurden, verschwinden aus der Bilanz.
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
let now = std::time::Instant::now();
if let Some(HistoryAction::SplitStrokes {
removed: prev_removed,
added: prev_added,
at,
}) = self.undo_stack.last_mut()
{
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
&& self.redo_stack.is_empty()
{
for stroke in removed {
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
prev_added.remove(pos);
} else {
prev_removed.push(stroke);
}
}
prev_added.extend(added);
*at = now;
return;
}
}
self.push(HistoryAction::SplitStrokes {
removed,
added,
at: now,
});
}
/// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
@@ -95,6 +136,12 @@ impl HistoryStack {
self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned());
self.redo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
}
true
@@ -140,6 +187,12 @@ impl HistoryStack {
self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after });
}
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned());
self.undo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
}
true
+444
View File
@@ -0,0 +1,444 @@
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
//! Papier und vor der Tinte.
//!
//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte
//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim
//! Herauszoomen nicht flimmern.
use std::collections::{HashMap, HashSet};
use crate::assets::RgbaImage;
/// 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 = "";
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben.
const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
/// Antwort einer Texturquelle für einen Schlüssel.
pub enum TextureSource {
Ready(RgbaImage),
/// Wird gerade erzeugt; später erneut fragen
Pending,
Failed(String),
}
pub struct ImageLayer {
pipeline: wgpu::RenderPipeline,
texture_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
textures: HashMap<String, (wgpu::BindGroup, u64)>,
texture_bytes: u64,
/// Bilder je Seite (Gruppe 0: aktuelle Seite, danach die sichtbaren Nachbarseiten):
/// Asset-Name und Ecken in Koordinaten der jeweiligen Seite
groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
vertex_buffer: Option<wgpu::Buffer>,
vertices_dirty: bool,
/// Importierte PDF-Seiten in höherer Auflösung (längste Kante in Pixeln) anzeigen;
/// bis sie gerendert ist, bleibt die Standardauflösung sichtbar
pdf_pixels: Option<u32>,
}
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(),
texture_bytes: 0,
groups: vec![Vec::new()],
vertex_buffer: None,
vertices_dirty: false,
pdf_pixels: None,
}
}
/// Ü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.groups[0] {
self.groups[0] = quads;
self.vertices_dirty = true;
}
}
/// Übernimmt die Bilder der sichtbaren Nachbarseiten (Gruppen 1, 2, …).
pub fn set_neighbor_quads(&mut self, neighbors: Vec<Vec<(String, [glam::Vec2; 4])>>) {
if neighbors[..] != self.groups[1..] {
self.groups.truncate(1);
self.groups.extend(neighbors);
self.vertices_dirty = true;
}
}
fn all_quads(&self) -> impl Iterator<Item = &(String, [glam::Vec2; 4])> {
self.groups.iter().flatten()
}
/// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen.
pub fn set_pdf_pixels(&mut self, pixels: Option<u32>) {
self.pdf_pixels = pixels;
}
/// Schlüssel der anzuzeigenden Textur: PDF-Seiten ggf. in höherer Auflösung.
fn texture_key(&self, key: &str) -> String {
match self.pdf_pixels {
Some(pixels) if key.contains('#') => format!("{key}@{pixels}"),
_ => key.to_string(),
}
}
/// 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,
load: &mut dyn FnMut(&str) -> TextureSource,
) {
let missing: Vec<String> = self
.all_quads()
.map(|(key, _)| self.texture_key(key))
.filter(|key| !self.textures.contains_key(key))
.collect();
for key in missing {
if self.textures.contains_key(&key) {
continue;
}
let (bind_group, bytes) = match load(&key) {
TextureSource::Ready(image) => (
self.upload_texture(device, queue, &key, &image),
image.pixels.len() as u64 * 4 / 3,
),
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
TextureSource::Pending => continue,
TextureSource::Failed(err) => {
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}");
(self.missing_texture(device, queue), 4)
}
};
self.texture_bytes += bytes;
self.textures.insert(key, (bind_group, bytes));
}
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
if self.texture_bytes > TEXTURE_CACHE_BYTES {
// Die Standardauflösung bleibt als Ersatz, bis die höhere fertig ist
let used: HashSet<String> = self
.all_quads()
.flat_map(|(key, _)| [key.clone(), self.texture_key(key)])
.collect();
self.textures.retain(|name, _| used.contains(name));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
}
if !self.vertices_dirty {
return;
}
self.vertices_dirty = false;
let vertices: Vec<ImageVertex> = self
.all_quads()
.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()
.is_none_or(|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);
}
}
/// Wird die Textur `key` von einer sichtbaren Seite verwendet?
pub fn uses(&self, key: &str) -> bool {
self.all_quads().any(|(k, _)| k == key)
}
/// Gibt alle Texturen frei, deren Schlüssel `matches` erfüllt (sie werden beim nächsten
/// `prepare` neu angefordert).
pub fn forget_textures(&mut self, mut matches: impl FnMut(&str) -> bool) {
self.textures.retain(|key, _| !matches(key));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
}
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
pub fn is_waiting(&self) -> bool {
self.all_quads()
.any(|(key, _)| !self.textures.contains_key(&self.texture_key(key)))
}
/// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der
/// Seite) muss gesetzt sein.
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>, group: usize) {
let Some(buffer) = &self.vertex_buffer else {
return;
};
let Some(quads) = self.groups.get(group) else {
return;
};
if quads.is_empty() || self.vertices_dirty {
return;
}
let first: usize = self.groups[..group].iter().map(Vec::len).sum();
render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in quads.iter().enumerate() {
let Some((bind_group, _)) = self
.textures
.get(&self.texture_key(asset))
.or_else(|| self.textures.get(asset))
else {
continue;
};
render_pass.set_bind_group(1, bind_group, &[]);
let start = ((first + 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);
}
}
}
+9
View File
@@ -1,17 +1,26 @@
//! 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 page_export;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod platform;
pub mod ruler;
pub mod selection;
pub mod settings;
pub mod spline;
pub mod storage;
pub mod text;
pub mod ui;
pub mod updater;
pub mod viewport;
+1257 -412
View File
File diff suppressed because it is too large Load Diff
+334
View File
@@ -0,0 +1,334 @@
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
//!
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
use std::error::Error;
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage};
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
/// Auflösung des PNG-Exports einer Seite.
pub const PAGE_PNG_DPI: f32 = 200.0;
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
pub const SELECTION_PNG_DPI: f32 = 150.0;
/// Hintergrund beim Bild-Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageBackground {
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
Paper,
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
Transparent,
}
/// Erzeugt ein einseitiges PDF aus `page`.
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, assets)
}
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
fn content_only(page: &Page) -> Page {
let mut page = page.clone();
page.background = PageBackground::Blank;
page.pdf_background = None;
page
}
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
let scale = dpi / 72.0;
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: if transparent { TRANSPARENT } else { WHITE },
},
);
let mut pixels = pixmap.data_as_u8_slice().to_vec();
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels,
})
}
/// Kodiert ein Bild als PNG.
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(buffer)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
Ok(out)
}
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
pub fn page_png(
page: &Page,
assets: Option<&AssetStore>,
background: ImageBackground,
) -> Result<Vec<u8>, Box<dyn Error>> {
let image = match background {
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
ImageBackground::Transparent => rasterize(
single_page_pdf(&content_only(page), assets)?,
PAGE_PNG_DPI,
true,
)?,
};
encode_png(&image)
}
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> {
let pdf =
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
Ok(hayro_svg::convert(
pdf_page,
&hayro_svg::RenderCache::new(),
&InterpreterSettings::default(),
&hayro_svg::SvgRenderSettings {
bg_color: [255, 255, 255, 255],
},
))
}
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
pub fn selection_image(
page: &Page,
ids: &[u64],
assets: Option<&AssetStore>,
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
let mut selected = content_only(page);
selected.strokes.retain(|s| ids.contains(&s.id));
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
return Ok(None);
};
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?;
// Seiteneinheiten → Pixel
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for y in y0..y1 {
let start = ((y * full.width + x0) * 4) as usize;
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
}
Ok(Some(RgbaImage {
width: w,
height: h,
pixels,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
let samples: Vec<StylusSample> = (0..=20)
.map(|i| StylusSample {
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
pressure: 0.6,
timestamp: i as f64 * 0.01,
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
[0.9, 0.1, 0.1, 1.0],
20.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
stroke.width = 20.0;
page.strokes.push(stroke);
page
}
fn decode(png: &[u8]) -> image::RgbaImage {
image::load_from_memory(png).unwrap().into_rgba8()
}
#[test]
fn test_page_png_paper_and_transparent() {
let page = page_with_line();
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
// A4 mit 200 dpi
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
// Die Linie ist rot und deckend
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
assert!(
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
"{line:?}"
);
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
}
#[test]
fn test_page_svg_contains_ink() {
let svg = page_svg(&page_with_line(), None).unwrap();
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.trim_end().ends_with("</svg>"));
}
/// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel.
fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) {
let mut b = (u32::MAX, u32::MAX, 0, 0);
for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() {
if p[3] > 128 {
let (x, y) = (i as u32 % width, i as u32 / width);
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
}
}
b
}
fn page_with_text() -> (Page, crate::text::TextBox) {
let text = crate::text::TextBox::new(
"Hallo Welt\nzweite Zeile".into(),
100.0,
crate::text::TextFont::Sans,
1200.0,
);
let size = glam::Vec2::new(1200.0, text.layout().height());
let corners = crate::text::frame_corners(
glam::Vec2::new(300.0, 400.0),
[glam::Vec2::X, glam::Vec2::Y],
size,
);
let mut page = Page::new(1, PageBackground::Grid5mm);
page.strokes.push(Stroke::new_text(
3,
text.clone(),
corners,
[0.0, 0.0, 0.0, 1.0],
));
(page, text)
}
#[test]
fn test_exported_text_matches_screen_rendering() {
let (page, text) = page_with_text();
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
let exported = ink_bounds(png.width(), png.as_raw());
// Dieselbe Stelle, wie sie die Bildschirmtextur zeigt
let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit);
let local = ink_bounds(screen.width, &screen.pixels);
let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32);
let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy);
for (a, b) in [
(exported.0, expected.0),
(exported.1, expected.1),
(exported.2, expected.2),
(exported.3, expected.3),
] {
assert!(
a.abs_diff(b) <= 3,
"PDF {exported:?} ≠ Bildschirm {expected:?}"
);
}
}
#[test]
fn test_exported_text_follows_rotation() {
let (mut page, _) = page_with_text();
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
let straight = ink_bounds(png.width(), png.as_raw());
let center = page.strokes[0].aabb.min;
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
let rotated = ink_bounds(png.width(), png.as_raw());
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1);
assert!(
rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4,
"{straight:?} → {rotated:?}"
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0);
}
#[test]
fn test_landscape_page_export_keeps_positions() {
// Strich unten rechts auf einer Querformat-Seite: im PNG an derselben Stelle
let mut page = page_with_line();
page.size = glam::Vec2::new(2970.0, 1671.0);
page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
let ratio = png.width() as f32 / png.height() as f32;
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
let px_per_unit = png.width() as f32 / 2970.0;
let at = |x: f32, y: f32| {
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
.0
};
assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts");
assert!(at(2600.0, 1300.0)[3] == 0);
let svg = page_svg(&page, None).unwrap();
assert!(svg.contains("<path"));
}
#[test]
fn test_selection_image_is_cropped() {
let page = page_with_line();
let image = selection_image(&page, &[7], None).unwrap().unwrap();
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
assert!(
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
"{} px",
image.width
);
assert!(image.height < image.width / 3);
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
assert!(selection_image(&page, &[99], None).unwrap().is_none());
}
}
+325
View File
@@ -0,0 +1,325 @@
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
//!
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::{LoadPdfError, Pdf};
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::RgbaImage;
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
pub fn parse_page_key(key: &str) -> Option<(&str, usize, u32)> {
let (asset, rest) = key.split_once('#')?;
let (page, pixels) = match rest.split_once('@') {
Some((page, pixels)) => (page, pixels.parse().ok()?),
None => (rest, DISPLAY_RENDER_PIXELS),
};
Some((asset, page.parse().ok()?, pixels))
}
/// Größte Seitenzahl, die importiert wird.
pub const MAX_IMPORT_PAGES: usize = 2000;
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
/// berücksichtigt).
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
let pdf = open(bytes)?;
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
if sizes.is_empty() {
return Err("Das PDF enthält keine Seiten".into());
}
if sizes.len() > MAX_IMPORT_PAGES {
return Err(format!(
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
sizes.len()
));
}
Ok(sizes)
}
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
Pdf::new(bytes).map_err(|e| match e {
LoadPdfError::Decryption(_) => {
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
.to_string()
}
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
})
}
/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist,
/// auf weißem Grund.
pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage, String> {
let page = pdf
.pages()
.get(index)
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
let (w, h) = page.render_dimensions();
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
},
);
// Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels: pixmap.data_as_u8_slice().to_vec(),
})
}
/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild).
pub fn render_file_page(
path: &std::path::Path,
index: usize,
max_pixels: u32,
) -> Result<RgbaImage, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
render_page(&open(bytes)?, index, max_pixels)
}
struct RenderRequest {
key: String,
path: PathBuf,
page: usize,
max_pixels: u32,
}
/// Ergebnis eines Render-Auftrags.
pub type RenderResult = Result<RgbaImage, String>;
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
pub struct PdfRenderService {
requests: Sender<RenderRequest>,
results: Receiver<(String, RenderResult)>,
pending: HashSet<String>,
ready: HashMap<String, RenderResult>,
}
impl PdfRenderService {
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
let (res_tx, res_rx) = mpsc::channel();
std::thread::Builder::new()
.name("pdf-render".into())
.spawn(move || {
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
while let Ok(request) = req_rx.recv() {
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
open_pdf = std::fs::read(&request.path)
.map_err(|e| e.to_string())
.and_then(open)
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
.ok()
.map(|pdf| (request.path.clone(), pdf));
}
let result = match &open_pdf {
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
None => Err("PDF kann nicht geöffnet werden".into()),
};
if res_tx.send((request.key, result)).is_err() {
break;
}
on_ready();
}
})
.expect("PDF-Render-Thread konnte nicht gestartet werden");
Self {
requests: req_tx,
results: res_rx,
pending: HashSet::new(),
ready: HashMap::new(),
}
}
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
/// [`Self::take`] liefert es später sofort.
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
self.collect();
if self.ready.contains_key(key) || self.pending.contains(key) {
return;
}
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
if self.ready.len() >= 2 {
self.ready.clear();
}
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
}
fn collect(&mut self) {
while let Ok((done, result)) = self.results.try_recv() {
self.pending.remove(&done);
self.ready.insert(done, result);
}
}
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
if !self.pending.insert(key.to_string()) {
return true;
}
let request = RenderRequest {
key: key.to_string(),
path,
page,
max_pixels,
};
if self.requests.send(request).is_err() {
self.pending.remove(key);
return false;
}
true
}
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
/// Die Seite wird so gerendert, dass die längste Kante `max_pixels` lang ist.
pub fn take(
&mut self,
key: &str,
path: PathBuf,
page: usize,
max_pixels: u32,
) -> Option<RenderResult> {
self.collect();
if let Some(result) = self.ready.remove(key) {
return Some(result);
}
if !self.request(key, path, page, max_pixels) {
return Some(Err("PDF-Render-Thread beendet".into()));
}
None
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
let mut objects: Vec<String> = Vec::new();
let n = pages.len();
// 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objects.push(format!(
"<< /Type /Pages /Kids [{}] /Count {n} >>",
kids.join(" ")
));
for (i, (w, h, rotate)) in pages.iter().enumerate() {
let content_id = 4 + 2 * i;
objects.push(format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>"
));
let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0);
objects.push(format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
));
}
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
}
let xref_pos = out.len();
out.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
out
}
#[test]
fn test_page_sizes_and_errors() {
let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]);
let sizes = page_sizes(pdf).unwrap();
assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]);
assert!(page_sizes(b"kein pdf".to_vec()).is_err());
}
#[test]
fn test_render_page_draws_content() {
let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap();
let image = render_page(&pdf, 0, 400).unwrap();
assert_eq!((image.width, image.height), (400, 200));
let px = |x: u32, y: u32| {
let i = ((y * image.width + x) * 4) as usize;
&image.pixels[i..i + 4]
};
// Rotes Quadrat oben links, sonst weiß
assert_eq!(px(20, 20), &[255, 0, 0, 255]);
assert_eq!(px(300, 150), &[255, 255, 255, 255]);
assert!(render_page(&pdf, 5, 400).is_err());
}
#[test]
fn test_render_service_delivers_in_background() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.pdf");
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap();
let (wake_tx, wake_rx) = mpsc::channel();
let mut service = PdfRenderService::new(move || {
let _ = wake_tx.send(());
});
let px = DISPLAY_RENDER_PIXELS;
assert!(service.take("a#0", path.clone(), 0, px).is_none());
// Zweite Anfrage erteilt keinen weiteren Auftrag
assert!(service.take("a#0", path.clone(), 0, px).is_none());
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
// Vorab rendern: danach sofort verfügbar
service.prefetch("a#0", path.clone(), 0);
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
}
#[test]
fn test_parse_page_key() {
assert_eq!(
parse_page_key("abc.pdf#3"),
Some(("abc.pdf", 3, DISPLAY_RENDER_PIXELS))
);
assert_eq!(parse_page_key("abc.pdf#3@5120"), Some(("abc.pdf", 3, 5120)));
assert_eq!(parse_page_key("abc.png"), None);
assert_eq!(parse_page_key("abc.pdf#x"), None);
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
//! Notizen (Text bleibt scharf und auswählbar).
//!
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
use std::collections::{BTreeMap, HashMap};
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
#[derive(Debug, Clone, PartialEq)]
pub struct Underlay {
/// Seitennummer im Ziel-PDF, ab 0
pub target_page: usize,
/// Schlüssel des Quell-PDFs in `sources`
pub source: String,
/// Seitennummer im Quell-PDF, ab 0
pub source_page: usize,
/// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links)
pub rect: [f32; 4],
}
/// Nicht übernommene Unterlagen samt Grund.
pub type FailedUnderlays = Vec<(Underlay, String)>;
/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
pub type Matrix = [f32; 6];
/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden.
pub fn compose(outer: Matrix, inner: Matrix) -> Matrix {
let [a1, b1, c1, d1, e1, f1] = outer;
let [a2, b2, c2, d2, e2, f2] = inner;
[
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
]
}
/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad
/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst
/// in `rect` (links, unten, Breite, Höhe).
pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix {
let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3));
let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]];
let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) {
90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w),
180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h),
270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w),
_ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h),
};
let scale = (rect[2] / shown_w).min(rect[3] / shown_h);
let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]];
compose(place, compose(rotation, to_origin))
}
/// Erbt ein Seitenattribut über die `Parent`-Kette.
fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> {
let mut node = page;
for _ in 0..64 {
if let Ok(value) = node.get(key) {
return Some(value);
}
let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?;
node = doc.get_dictionary(parent).ok()?;
}
None
}
fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object {
doc.dereference(object).map(|(_, o)| o).unwrap_or(object)
}
fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> {
let array = resolve(doc, object?).as_array().ok()?;
let values: Vec<f32> = array
.iter()
.filter_map(|v| resolve(doc, v).as_float().ok())
.collect();
let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?;
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject.
/// Liefert das XObject und die Matrix für die Platzierung in `rect`.
fn page_as_form(
doc: &Document,
page_id: ObjectId,
rect: [f32; 4],
) -> Result<(Stream, Matrix), String> {
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
let media =
read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]);
let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) {
// Sichtbarer Bereich: Schnitt aus Crop- und Media-Box
Some(c) => [
c[0].max(media[0]),
c[1].max(media[1]),
c[2].min(media[2]),
c[3].min(media[3]),
],
None => media,
};
let rotate = inherited(doc, page, b"Rotate")
.and_then(|r| resolve(doc, r).as_i64().ok())
.unwrap_or(0);
let resources = inherited(doc, page, b"Resources")
.cloned()
.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
// Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen,
// mehrere werden entpackt und aneinandergehängt
let streams = doc.get_page_contents(page_id);
let (content, filter) = match streams.as_slice() {
[single] => {
let stream = doc
.get_object(*single)
.and_then(Object::as_stream)
.map_err(|e| e.to_string())?;
let filter = ["Filter", "DecodeParms"]
.into_iter()
.filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone())))
.collect::<Vec<_>>();
(stream.content.clone(), filter)
}
_ => {
let mut content = Vec::new();
for id in streams {
let stream = doc
.get_object(id)
.and_then(Object::as_stream)
.map_err(|e| e.to_string())?;
let data = stream
.decompressed_content()
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
content.extend_from_slice(&data);
content.push(b'\n');
}
(content, Vec::new())
}
};
let mut dict = Dictionary::new();
dict.set("Type", Object::Name(b"XObject".to_vec()));
dict.set("Subtype", Object::Name(b"Form".to_vec()));
dict.set(
"BBox",
Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()),
);
dict.set("Resources", resources);
for (key, value) in filter {
dict.set(key, value);
}
Ok((
Stream::new(dict, content),
placement_matrix(bbox, rotate, rect),
))
}
/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes.
/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten
/// (z. B. verschlüsselte PDFs), samt Grund.
pub fn apply_underlays(
target_bytes: &[u8],
underlays: &[Underlay],
sources: &HashMap<String, Vec<u8>>,
) -> Result<(Vec<u8>, FailedUnderlays), String> {
let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?;
let target_pages: Vec<ObjectId> = target.get_pages().into_values().collect();
let mut failed = Vec::new();
// Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen
let mut loaded: HashMap<&str, Result<BTreeMap<u32, ObjectId>, String>> = HashMap::new();
for underlay in underlays {
if loaded.contains_key(underlay.source.as_str()) {
continue;
}
let result = (|| {
let bytes = sources
.get(&underlay.source)
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
if source.is_encrypted() {
return Err("Quell-PDF ist verschlüsselt".to_string());
}
source.renumber_objects_with(target.max_id + 1);
let pages = source.get_pages();
target.max_id = target.max_id.max(source.max_id);
// Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern
// mit `prune_objects` entfernt
target.objects.extend(source.objects);
Ok(pages)
})();
loaded.insert(&underlay.source, result);
}
for underlay in underlays {
let result = (|| {
let pages = loaded[underlay.source.as_str()]
.as_ref()
.map_err(Clone::clone)?;
let source_page = *pages
.get(&(underlay.source_page as u32 + 1))
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
let target_page = *target_pages
.get(underlay.target_page)
.ok_or_else(|| "Zielseite fehlt".to_string())?;
let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?;
let form_id = target.add_object(Object::Stream(form));
let name = format!("InkPdf{}", form_id.0);
add_xobject(&mut target, target_page, &name, form_id)?;
let ops = format!(
"q {} {} {} {} {} {} cm /{name} Do Q\n",
matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]
);
prepend_content(&mut target, target_page, ops.into_bytes())
})();
if let Err(err) = result {
failed.push((underlay.clone(), err));
}
}
target.prune_objects();
// Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert)
target.compress();
let mut out = Vec::new();
target.save_to(&mut out).map_err(|e| e.to_string())?;
Ok((out, failed))
}
/// Trägt ein XObject in die Ressourcen der Seite ein.
fn add_xobject(
doc: &mut Document,
page_id: ObjectId,
name: &str,
id: ObjectId,
) -> Result<(), String> {
// Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen)
let resources = {
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) {
Some(Object::Dictionary(d)) => d.clone(),
_ => Dictionary::new(),
}
};
let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) {
Ok(Object::Dictionary(d)) => d.clone(),
_ => Dictionary::new(),
};
xobjects.set(name, Object::Reference(id));
let mut resources = resources;
resources.set("XObject", Object::Dictionary(xobjects));
let page = doc
.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| e.to_string())?;
page.set("Resources", Object::Dictionary(resources));
Ok(())
}
/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst).
fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec<u8>) -> Result<(), String> {
let existing: Vec<Object> = doc
.get_page_contents(page_id)
.into_iter()
.map(Object::Reference)
.collect();
let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content)));
let mut contents = vec![Object::Reference(id)];
contents.extend(existing);
let page = doc
.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| e.to_string())?;
page.set("Contents", Object::Array(contents));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
}
#[test]
fn test_placement_matrix() {
// Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20)
let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]);
assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0)));
assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0)));
// Verschobene MediaBox
let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]);
assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0)));
// Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200):
// die linke untere Ecke landet links oben
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]);
assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0)));
assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0)));
assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0)));
// 180° und 270° (= -90°)
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]);
assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0)));
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]);
assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0)));
}
#[test]
fn test_apply_underlays_embeds_source_pages() {
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]);
let sources = HashMap::from([("folien".to_string(), source)]);
let underlays = vec![
Underlay {
target_page: 0,
source: "folien".into(),
source_page: 0,
rect: [0.0, 421.0, 595.0, 421.0],
},
Underlay {
target_page: 1,
source: "folien".into(),
source_page: 5,
rect: [0.0, 0.0, 595.0, 842.0],
},
Underlay {
target_page: 1,
source: "fehlt".into(),
source_page: 0,
rect: [0.0, 0.0, 595.0, 842.0],
},
];
let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap();
// Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt
assert_eq!(failed.len(), 2);
let doc = Document::load_mem(&bytes).unwrap();
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
assert_eq!(
pages.len(),
2,
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
);
let first = doc.get_page_content(pages[0]).unwrap();
let text = String::from_utf8_lossy(&first);
assert!(
text.starts_with("q "),
"Unterlage muss zuerst gezeichnet werden: {text}"
);
assert!(text.contains("Do Q"));
// Ursprünglicher Seiteninhalt bleibt erhalten
assert!(text.contains("re f"));
// Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen
let page = doc.get_dictionary(pages[0]).unwrap();
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap();
let (_, form_ref) = xobjects.iter().next().unwrap();
let form = doc
.get_object(form_ref.as_reference().unwrap())
.unwrap()
.as_stream()
.unwrap();
assert_eq!(
form.dict.get(b"Subtype").unwrap().as_name().unwrap(),
b"Form"
);
assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg"));
}
}
+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
);
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
//!
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
use glam::Vec2;
use crate::document::UNITS_PER_MM;
/// Länge des Lineals (26 cm).
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
/// Breite des Lineals (3,2 cm).
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
/// Radius des Dreh-Griffs.
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
/// Winkel rasten in diesen Schritten ein …
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
/// … wenn sie höchstens so weit davon entfernt sind.
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
/// Lage des Lineals auf dem Blatt.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ruler {
/// Mittelpunkt
pub center: Vec2,
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
pub angle: f32,
}
/// Was an einer Stelle des Lineals liegt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RulerHit {
/// Fläche zum Verschieben
Body,
/// Griff zum Drehen
RotateHandle,
}
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EdgeLock {
pub origin: Vec2,
pub dir: Vec2,
}
impl EdgeLock {
/// Fußpunkt von `p` auf der Geraden.
pub fn project(&self, p: Vec2) -> Vec2 {
self.origin + self.dir * (p - self.origin).dot(self.dir)
}
}
impl Ruler {
pub fn new(center: Vec2) -> Self {
Self { center, angle: 0.0 }
}
/// Einheitsvektor entlang der Längsachse.
pub fn axis(&self) -> Vec2 {
Vec2::from_angle(self.angle)
}
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
pub fn normal(&self) -> Vec2 {
self.axis().perp()
}
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
pub fn local(&self, p: Vec2) -> Vec2 {
let d = p - self.center;
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
}
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
pub fn corners(&self) -> [Vec2; 4] {
let a = self.axis() * (RULER_LENGTH / 2.0);
let n = self.normal() * (RULER_WIDTH / 2.0);
[
self.center - a - n,
self.center + a - n,
self.center + a + n,
self.center - a + n,
]
}
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
pub fn handle_center(&self) -> Vec2 {
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
}
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
/// eingerastete Striche.
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
return Some(RulerHit::RotateHandle);
}
let l = self.local(p);
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
}
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
let l = self.local(p);
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
return None;
}
let half = RULER_WIDTH / 2.0;
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
1.0
} else if (l.y + half).abs() <= SNAP_DISTANCE {
-1.0
} else {
return None;
};
Some(EdgeLock {
origin: self.center + self.normal() * side * (half + offset),
dir: self.axis(),
})
}
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
/// 15° rasten ein.
pub fn rotate_toward(&mut self, toward: Vec2) {
let d = toward - self.center;
if d.length_squared() < 1e-6 {
return;
}
self.angle = snap_angle(d.y.atan2(d.x));
}
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
pub fn display_degrees(&self) -> u32 {
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
}
}
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
pub fn snap_angle(angle: f32) -> f32 {
let deg = angle.to_degrees();
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
nearest.to_radians()
} else {
angle
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 1e-2
}
#[test]
fn test_geometry_and_hits() {
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
let [tl, tr, br, bl] = ruler.corners();
assert!(close(
tl,
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
));
assert!(close(
br,
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
));
assert!(close(
tr - tl,
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
));
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
assert_eq!(
ruler.hit(ruler.handle_center()),
Some(RulerHit::RotateHandle)
);
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
assert_eq!(
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
None
);
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
}
#[test]
fn test_edge_lock_projects_onto_offset_edge() {
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
let lock = ruler
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
.unwrap();
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
// Knapp innerhalb der unteren Kante
let lock = ruler
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
.unwrap();
assert!(close(
lock.project(Vec2::new(0.0, 0.0)),
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
));
// Mitte oder weit weg: kein Einrasten
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
assert!(ruler
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
.is_none());
// Gedrehtes Lineal: die Linie folgt der Drehung
let mut tilted = ruler;
tilted.angle = 30f32.to_radians();
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
let a = lock.project(near_edge);
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
let dir = (b - a).normalize();
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
}
#[test]
fn test_rotation_snaps_to_15_degrees() {
let mut ruler = Ruler::new(Vec2::ZERO);
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 45);
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 37);
// Gegenrichtung zeigt denselben Winkel
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
assert_eq!(ruler.display_degrees(), 30);
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
}
}
+32 -1
View File
@@ -243,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;
}
@@ -499,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]);
}
}
+154 -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,85 @@ 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,
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
pub continuous_scroll: bool,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
sidebar_side: SidebarSide::default(),
paper_theme: PaperTheme::default(),
adapt_ink_colors: true,
pressure_curve: PressureCurve::default(),
continuous_scroll: true,
}
}
}
impl UiSettings {
@@ -37,7 +112,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 +121,9 @@ impl UiSettings {
Self::default()
}),
Err(_) => Self::default(),
}
};
settings.pressure_curve = settings.pressure_curve.sanitized();
settings
}
/// Speichert die Einstellungen atomar.
@@ -69,6 +146,13 @@ 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,
},
continuous_scroll: false,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
@@ -81,4 +165,71 @@ 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());
assert!(settings.continuous_scroll);
// 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);
}
+503 -13
View File
@@ -70,6 +70,12 @@ 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>,
/// Textfeld; die Samples sind dann die vier Ecken des Felds
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<crate::text::TextBox>,
}
impl From<&Stroke> for StrokeData {
@@ -82,6 +88,8 @@ impl From<&Stroke> for StrokeData {
tool: stroke.tool,
width: stroke.width,
finalized: stroke.finalized,
image: stroke.image.clone(),
text: stroke.text.clone(),
}
}
}
@@ -96,6 +104,25 @@ impl StrokeData {
.copied()
.map(StylusSample::from)
.collect();
if let (Some(text), [a, b, c, d, ..]) = (&self.text, stylus_samples.as_slice()) {
return Stroke::new_text(
self.id,
text.clone(),
[a.position, b.position, c.position, d.position],
self.color,
);
}
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 +144,30 @@ 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.
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
/// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich).
/// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
pub const CURRENT_FORMAT_VERSION: u32 = 8;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 {
@@ -139,15 +181,42 @@ pub struct PageData {
pub format_version: u32,
pub id: u64,
pub background: PageBackground,
/// Importierte PDF-Seite als Hintergrund
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf_background: Option<crate::document::PdfBackground>,
/// Seitentitel (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Seitengröße in Seiteneinheiten, wenn nicht DIN A4 hoch (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
pub strokes: Vec<StrokeData>,
}
/// Gespeicherte Seitengröße: `None` für DIN A4 hoch.
fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
(size != crate::document::A4_SIZE).then(|| size.to_array())
}
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored {
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
}
_ => crate::document::A4_SIZE,
}
}
impl From<&Page> for PageData {
fn from(page: &Page) -> Self {
Self {
format_version: CURRENT_FORMAT_VERSION,
id: page.id,
background: page.background,
pdf_background: page.pdf_background.clone(),
title: page.title.clone(),
size: stored_size(page.size),
strokes: page.strokes.iter().map(StrokeData::from).collect(),
}
}
@@ -156,7 +225,9 @@ impl From<&Page> for PageData {
impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background);
let mut page = Page::new(self.id, self.background).with_size(page_size_from(self.size));
page.pdf_background = self.pdf_background.clone();
page.title = self.title.clone();
for stroke_data in &self.strokes {
page.strokes.push(stroke_data.to_stroke());
}
@@ -173,11 +244,22 @@ pub struct PageMeta {
pub id: u64,
#[serde(default)]
pub background: Option<PageBackground>,
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Seitengröße, wenn nicht DIN A4 hoch (für Lage und Maßstab ungeladener Seiten)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
}
impl PageMeta {
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
Self { id, background }
Self {
id,
background,
title: None,
size: None,
}
}
}
@@ -227,6 +309,22 @@ impl NotebookManifest {
}
}
pub fn title_for_page(&self, page_id: u64) -> Option<String> {
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.title.clone())
}
pub fn size_for_page(&self, page_id: u64) -> glam::Vec2 {
page_size_from(
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.size),
)
}
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
self.pages
.iter()
@@ -242,6 +340,8 @@ impl NotebookManifest {
.map(|p| PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
size: stored_size(p.size),
})
.collect();
self.page_ids = self.pages.iter().map(|p| p.id).collect();
@@ -652,7 +752,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: q_path,
},
);
let mut page = Page::new(id, first_bg);
let mut page = Page::new(id, first_bg).with_size(manifest.size_for_page(id));
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
@@ -668,13 +768,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: None,
},
);
let mut page = Page::new(first_id, first_bg);
let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
let mut page = Page::new(first_id, first_bg);
let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
@@ -686,7 +788,14 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
let bg = manifest
.background_for_page(page_id)
.unwrap_or(PageBackground::Grid5mm);
pages.push(Page::new_unloaded(page_id, bg));
pages.push(Page::new_unloaded(page_id, bg).with_size(manifest.size_for_page(page_id)));
}
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
for page in &mut pages {
if page.title.is_none() {
page.title = manifest.title_for_page(page.id);
}
}
let mut notebook = Notebook::from_pages(
@@ -697,6 +806,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))
}
@@ -717,7 +835,18 @@ pub fn ensure_page_loaded(
let page_id = notebook.pages[page_idx].id;
match load_page(notebook_dir, page_id) {
Ok(data) => {
notebook.pages[page_idx] = data.to_page();
let title = notebook.pages[page_idx].title.take();
let mut page = data.to_page();
page.title = page.title.or(title);
notebook.pages[page_idx] = page;
Ok(())
}
// Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler
Err(StorageError::PageNotFound(_)) => {
let placeholder = &mut notebook.pages[page_idx];
let mut page = Page::new(page_id, placeholder.background).with_size(placeholder.size);
page.title = placeholder.title.take();
*placeholder = page;
Ok(())
}
Err(StorageError::CorruptedPage(id, err_msg)) => {
@@ -1021,6 +1150,51 @@ pub fn create_new_notebook(
Ok((notebook, manifest, notebook_dir))
}
/// Legt ein neues Notizbuch aus einem PDF an: eine Seite je PDF-Seite, die PDF-Seite als
/// Hintergrund. Das PDF wird unverändert im Asset-Ordner abgelegt; alle Seiten werden sofort
/// gespeichert.
pub fn create_notebook_from_pdf(
base_dir: &Path,
title: &str,
pdf_bytes: Vec<u8>,
) -> Result<(Notebook, NotebookManifest, PathBuf), String> {
let sizes = crate::pdf_doc::page_sizes(pdf_bytes.clone())?;
let (_, _, dir) = create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
let result = (|| {
let asset = crate::assets::AssetStore::new(&dir)
.import_pdf(&pdf_bytes)
.map_err(|e| e.to_string())?;
let pages: Vec<Page> = sizes
.iter()
.enumerate()
.map(|(i, &(width_pt, height_pt))| {
let mut page = Page::new(i as u64 + 1, PageBackground::Blank)
.with_size(crate::document::pdf_page_size(width_pt, height_pt));
page.pdf_background = Some(crate::document::PdfBackground {
asset: asset.clone(),
page: i as u32,
width_pt,
height_pt,
});
page
})
.collect();
let page_count = pages.len() as u64;
let notebook = Notebook::from_pages(pages, 0, 1, page_count + 1);
let mut manifest = load_manifest(&dir).map_err(|e| e.to_string())?;
save_notebook(&dir, &notebook, &mut manifest).map_err(|e| e.to_string())?;
Ok((notebook, manifest))
})();
match result {
Ok((notebook, manifest)) => Ok((notebook, manifest, dir)),
Err(err) => {
// Halbfertiges Notizbuch nicht liegen lassen
let _ = fs::remove_dir_all(&dir);
Err(err)
}
}
}
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
pub fn rename_notebook(
notebook_dir: &Path,
@@ -1217,12 +1391,16 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
));
}
if !page.is_loaded {
let title = page.title.take();
*page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
Err(StorageError::PageNotFound(_)) => {
Page::new(page.id, page.background).with_size(page.size)
}
Err(e) => return Err(e),
};
page.title = page.title.take().or(title);
}
}
Ok(())
@@ -2160,11 +2338,323 @@ 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_never_saved_page_loads_as_empty_page() {
// Angehängte, nie beschriebene Seite: steht im Manifest, hat aber keine Datei
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Angehängt").unwrap();
notebook.add_page(PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let _ = fs::remove_file(dir.join(format!("page_{}.json", notebook.pages[1].id)));
let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded);
ensure_page_loaded(&dir, &mut loaded, 1).unwrap();
let page = &loaded.pages[1];
assert!(page.is_loaded && !page.is_readonly());
assert_eq!(page.background, PageBackground::Lined);
assert!(page.strokes.is_empty());
assert!(loaded.load_warnings.is_empty());
}
#[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,
CURRENT_FORMAT_VERSION
);
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());
}
#[test]
fn test_text_boxes_round_trip_through_page_file() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Text").unwrap();
let text = crate::text::TextBox::new(
"Überschrift\n\tfn main() {}".into(),
42.0,
crate::text::TextFont::Mono,
900.0,
);
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(1000.0, 100.0),
glam::Vec2::new(1000.0, 220.0),
glam::Vec2::new(100.0, 220.0),
];
notebook.current_page_mut().strokes.push(Stroke::new_text(
8,
text.clone(),
corners,
[0.15, 0.35, 0.85, 1.0],
));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (reloaded, _) = load_notebook(&dir).unwrap();
let stroke = &reloaded.current_page().strokes[0];
assert_eq!(stroke.text.as_ref(), Some(&text));
assert_eq!(stroke.object_corners(), Some(corners));
assert_eq!(stroke.color, [0.15, 0.35, 0.85, 1.0]);
assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty());
}
#[test]
fn test_page_size_round_trips_and_is_known_before_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Folien").unwrap();
let slide = glam::Vec2::new(2970.0, 1671.0);
notebook.pages[0].size = slide;
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
notebook.pages[2].size = crate::document::A4_SIZE;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut reloaded, _) = load_notebook(&dir).unwrap();
let sizes: Vec<glam::Vec2> = reloaded.pages.iter().map(|p| p.size).collect();
assert_eq!(sizes, vec![slide, slide, crate::document::A4_SIZE]);
// Ungeladene Platzhalter kennen ihre Größe aus dem Manifest
assert!(!reloaded.pages[1].is_loaded);
ensure_page_loaded(&dir, &mut reloaded, 1).unwrap();
assert_eq!(reloaded.pages[1].size, slide);
// A4-Seiten speichern keine Größe (Dateien bleiben wie bisher)
let data = load_page(&dir, reloaded.pages[2].id).unwrap();
assert_eq!(data.size, None);
// Unsinnige Werte aus einer bearbeiteten Datei ergeben A4
assert_eq!(
page_size_from(Some([f32::NAN, 5.0])),
crate::document::A4_SIZE
);
}
#[test]
fn test_create_notebook_from_pdf() {
let temp_dir = tempfile::tempdir().unwrap();
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
let (notebook, manifest, dir) =
create_notebook_from_pdf(temp_dir.path(), "Vorlesung 3", pdf.clone()).unwrap();
assert_eq!(manifest.title, "Vorlesung 3");
assert_eq!(notebook.pages.len(), 2);
assert_eq!(notebook.next_page_id, 3);
// Nach dem Neuladen: jede Seite verweist auf ihre PDF-Seite, das PDF liegt unverändert
// im Asset-Ordner
let (mut reloaded, _) = load_notebook(&dir).unwrap();
load_all_pages(&dir, &mut reloaded).unwrap();
for (i, page) in reloaded.pages.iter().enumerate() {
let bg = page.pdf_background.as_ref().expect("PDF-Hintergrund fehlt");
assert_eq!(bg.page, i as u32);
assert_eq!(page.background, PageBackground::Blank);
}
let first = reloaded.pages[0].pdf_background.clone().unwrap();
assert_eq!((first.width_pt, first.height_pt), (842.0, 595.0));
let assets = crate::assets::AssetStore::new(&dir);
assert_eq!(assets.read_bytes(&first.asset).unwrap(), pdf);
// Kein PDF: Fehler und kein halbfertiges Notizbuch
let before = std::fs::read_dir(temp_dir.path()).unwrap().count();
assert!(
create_notebook_from_pdf(temp_dir.path(), "Kaputt", b"%PDF-kaputt".to_vec()).is_err()
);
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
}
#[test]
fn test_page_titles_survive_restart_and_lazy_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Mit Titeln").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.add_page(PageBackground::Lined);
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
assert!(notebook.set_page_title(ids[0], " Einleitung \n"));
assert!(notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
assert!(!notebook.set_page_title(ids[2], "Kapitel 2 – Integrale"));
save_notebook(&dir, &notebook, &mut manifest).unwrap();
assert_eq!(manifest.pages[0].title.as_deref(), Some("Einleitung"));
// Neustart: Folgeseiten sind Platzhalter, kennen ihren Titel aber aus dem Manifest
let (mut reloaded, _) = load_notebook(&dir).unwrap();
assert!(!reloaded.pages[2].is_loaded);
assert_eq!(
reloaded.pages[2].title.as_deref(),
Some("Kapitel 2 – Integrale")
);
ensure_page_loaded(&dir, &mut reloaded, 2).unwrap();
assert_eq!(
reloaded.pages[2].title.as_deref(),
Some("Kapitel 2 – Integrale")
);
assert_eq!(reloaded.pages[1].title, None);
// Titel entfernen
assert!(reloaded.set_page_title(ids[0], " "));
assert_eq!(reloaded.pages[0].title, None);
}
}
+546
View File
@@ -0,0 +1,546 @@
//! Textfelder (Text-Werkzeug): Datenmodell, Zeilenumbruch und Rasterung.
//!
//! Ein Textfeld ist ein Objekt mit vier Ecken wie ein Bild (siehe `Stroke::text`). Der Text
//! wird in einem eigenen Koordinatensystem gesetzt – Breite = Umbruchbreite, Höhe = Zeilen ×
//! Zeilenhöhe, in Seiteneinheiten – und dieses Rechteck auf die Ecken abgebildet. So wirken
//! Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle auf Text.
//!
//! Bildschirm (Textur), PDF-Export (eingebettete Schrift, durchsuchbar) und Bearbeitung (egui)
//! verwenden dieselben Schriften aus egui und denselben Zeilenumbruch.
use std::sync::OnceLock;
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
use serde::{Deserialize, Serialize};
use crate::assets::RgbaImage;
use crate::document::{Stroke, UNITS_PER_MM};
/// Schriftart eines Textfelds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum TextFont {
/// Proportionalschrift (Ubuntu Light) für Überschriften und Notizen
#[default]
Sans,
/// Festbreitenschrift (Hack) für Code
Mono,
}
impl TextFont {
pub fn label(self) -> &'static str {
match self {
TextFont::Sans => "Normal",
TextFont::Mono => "Code",
}
}
/// Name der Schrift in den egui-Standardschriften.
fn egui_name(self) -> &'static str {
match self {
TextFont::Sans => "Ubuntu-Light",
TextFont::Mono => "Hack",
}
}
/// egui-Schriftfamilie, deren erste Schrift diese ist (für die Bearbeitung).
pub fn egui_family(self) -> egui::FontFamily {
match self {
TextFont::Sans => egui::FontFamily::Proportional,
TextFont::Mono => egui::FontFamily::Monospace,
}
}
/// TrueType-Daten der Schrift (aus den in egui eingebauten Schriften).
pub fn bytes(self) -> &'static [u8] {
static DEFINITIONS: OnceLock<egui::FontDefinitions> = OnceLock::new();
let definitions = DEFINITIONS.get_or_init(egui::FontDefinitions::default);
&definitions.font_data[self.egui_name()].font
}
pub fn face(self) -> &'static FontRef<'static> {
static SANS: OnceLock<FontRef<'static>> = OnceLock::new();
static MONO: OnceLock<FontRef<'static>> = OnceLock::new();
let cell = match self {
TextFont::Sans => &SANS,
TextFont::Mono => &MONO,
};
cell.get_or_init(|| {
FontRef::try_from_slice(self.bytes()).expect("eingebaute egui-Schrift ist gültig")
})
}
fn units_per_em(self) -> f32 {
self.face().units_per_em().unwrap_or(1000.0)
}
/// Höhe von Ober- bis Unterlänge je Em: ab_glyph skaliert auf diese Höhe, PDF, egui und
/// dieses Modul messen die Schriftgröße als Em.
pub fn height_per_em(self) -> f32 {
let face = self.face();
(face.ascent_unscaled() - face.descent_unscaled()) / self.units_per_em()
}
}
/// Seiteneinheiten je typografischem Punkt.
pub const UNITS_PER_PT: f32 = 25.4 / 72.0 * UNITS_PER_MM;
/// Wählbare Schriftgrößen in Punkt.
pub const TEXT_SIZES_PT: [f32; 7] = [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0];
pub const DEFAULT_TEXT_SIZE_PT: f32 = 12.0;
/// Kleinste Umbruchbreite eines neuen Textfelds (3 cm).
pub const MIN_WRAP_WIDTH: f32 = 300.0;
/// Abstand neuer Textfelder zum rechten Blattrand (1 cm).
pub const RIGHT_MARGIN: f32 = 100.0;
/// Ein Tabulator wird durch so viele Leerzeichen ersetzt.
const TAB_WIDTH: usize = 4;
/// Inhalt und Satz eines Textfelds.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextBox {
pub content: String,
/// Schriftgröße (Em) in Seiteneinheiten
pub size: f32,
#[serde(default)]
pub font: TextFont,
/// Umbruchbreite in Seiteneinheiten (Breite des ungedrehten, unskalierten Felds)
pub wrap_width: f32,
}
/// Eine gesetzte Zeile: Zeichen (ohne Zeichen, die die Schrift nicht kennt) und ihre
/// Glyphen mit x-Position.
#[derive(Debug, Clone, PartialEq)]
pub struct LaidLine {
pub text: String,
pub glyphs: Vec<(GlyphId, f32)>,
pub width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextLayout {
pub lines: Vec<LaidLine>,
/// Oberlänge (Abstand Zeilenoberkante → Grundlinie)
pub ascent: f32,
pub line_height: f32,
}
impl TextLayout {
/// Höhe des Felds; ein leeres Feld ist eine Zeile hoch.
pub fn height(&self) -> f32 {
self.lines.len().max(1) as f32 * self.line_height
}
/// Grundlinie der Zeile `i` (von der Oberkante des Felds).
pub fn baseline(&self, i: usize) -> f32 {
i as f32 * self.line_height + self.ascent
}
}
impl TextBox {
pub fn new(content: String, size: f32, font: TextFont, wrap_width: f32) -> Self {
Self {
content,
size,
font,
wrap_width,
}
}
/// Größe des ungedrehten, unskalierten Felds in Seiteneinheiten.
pub fn box_size(&self) -> glam::Vec2 {
glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height())
}
/// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise
/// umbrochen, Zeilenumbrüche im Text bleiben erhalten.
pub fn layout(&self) -> TextLayout {
let face = self.font.face();
let em = self.size / self.font.units_per_em();
let line_height =
(face.ascent_unscaled() - face.descent_unscaled() + face.line_gap_unscaled()) * em;
let width = self.wrap_width.max(self.size);
let mut lines = Vec::new();
for paragraph in self.content.split('\n') {
let mut chars: Vec<(char, GlyphId, f32)> = paragraph
.chars()
.flat_map(|c| {
let (c, n) = if c == '\t' { (' ', TAB_WIDTH) } else { (c, 1) };
std::iter::repeat_n(c, n)
})
.filter_map(|c| {
let id = face.glyph_id(c);
(id.0 != 0).then(|| (c, id, face.h_advance_unscaled(id) * em))
})
.collect();
// Unterschneidung (wie egui) dem vorderen Zeichen des Paars zurechnen
for i in 1..chars.len() {
chars[i - 1].2 += face.kern_unscaled(chars[i - 1].1, chars[i].1) * em;
}
let mut start = 0;
let mut x = 0.0;
// Letztes Leerzeichen der Zeile (möglicher Umbruch)
let mut last_space: Option<usize> = None;
for i in 0..chars.len() {
let (c, _, advance) = chars[i];
if c != ' ' && x + advance > width && i > start {
let end = match last_space {
Some(space) if space > start => space,
_ => i,
};
lines.push(laid_line(&chars[start..end]));
start = end;
while start < i && chars[start].0 == ' ' {
start += 1;
}
x = chars[start..i].iter().map(|g| g.2).sum();
last_space = None;
}
x += advance;
if c == ' ' {
last_space = Some(i);
}
}
lines.push(laid_line(&chars[start..]));
}
// Leere Zeilen am Ende zählen mit (Platz für den Cursor), ein leerer Text ist eine Zeile
TextLayout {
lines,
ascent: face.ascent_unscaled() * em,
line_height,
}
}
}
fn laid_line(chars: &[(char, GlyphId, f32)]) -> LaidLine {
let mut x = 0.0;
let mut glyphs = Vec::with_capacity(chars.len());
let mut width: f32 = 0.0;
for &(c, id, advance) in chars {
glyphs.push((id, x));
x += advance;
if c != ' ' {
width = x;
}
}
LaidLine {
text: chars.iter().map(|g| g.0).collect(),
glyphs,
width,
}
}
/// Ecken eines Felds der Größe `size` mit Ursprung (oben links) `origin` und den Achsen
/// `axes` (Seitenvektor je Einheit nach rechts bzw. unten): oben links, oben rechts,
/// unten rechts, unten links.
pub fn frame_corners(
origin: glam::Vec2,
axes: [glam::Vec2; 2],
size: glam::Vec2,
) -> [glam::Vec2; 4] {
let right = axes[0] * size.x;
let down = axes[1] * size.y;
[origin, origin + right, origin + right + down, origin + down]
}
/// Ursprung und Achsen (je Einheit) eines Felds der Größe `size` mit den Ecken `corners`.
pub fn frame_axes(corners: &[glam::Vec2; 4], size: glam::Vec2) -> (glam::Vec2, [glam::Vec2; 2]) {
let [tl, tr, _, bl] = *corners;
(
tl,
[(tr - tl) / size.x.max(1e-3), (bl - tl) / size.y.max(1e-3)],
)
}
/// Laufende Bearbeitung eines Textfelds (neu oder vorhanden).
#[derive(Debug, Clone, PartialEq)]
pub struct TextEditing {
/// Seite, auf der das Feld liegt
pub page_id: u64,
/// Bearbeitetes vorhandenes Feld im Zustand vor der Bearbeitung; `None` bei einem neuen
pub original: Option<Stroke>,
/// Ecke oben links und Achsen (Seitenvektor je Einheit nach rechts bzw. unten)
pub origin: glam::Vec2,
pub axes: [glam::Vec2; 2],
pub text: TextBox,
pub color: [f32; 4],
/// Beim nächsten Bild den Tastaturfokus auf das Feld setzen
pub focus: bool,
/// Bearbeitung beenden (Esc)
pub finish: bool,
}
impl TextEditing {
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
/// Rand eines Blatts der Breite `page_width` (mindestens 3 cm breit).
pub fn new_at(
page_id: u64,
pos: glam::Vec2,
page_width: f32,
size: f32,
font: TextFont,
color: [f32; 4],
) -> Self {
let right = page_width - RIGHT_MARGIN;
let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0);
let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH));
let line_height = text.layout().line_height;
Self {
page_id,
original: None,
origin: glam::Vec2::new(x, (pos.y - line_height / 2.0).max(0.0)),
axes: [glam::Vec2::X, glam::Vec2::Y],
text,
color,
focus: true,
finish: false,
}
}
/// Bearbeitung des vorhandenen Textfelds `stroke`.
pub fn of_stroke(page_id: u64, stroke: &Stroke) -> Option<Self> {
let text = stroke.text.clone()?;
let corners = stroke.object_corners()?;
let (origin, axes) = frame_axes(&corners, text.box_size());
Some(Self {
page_id,
original: Some(stroke.clone()),
origin,
axes,
text,
color: stroke.color,
focus: true,
finish: false,
})
}
/// Wurde gegenüber dem Ausgangszustand etwas geändert?
pub fn is_changed(&self) -> bool {
match &self.original {
Some(original) => {
original.text.as_ref() != Some(&self.text) || original.color != self.color
}
None => !self.is_empty(),
}
}
/// Enthält das Feld nur Leerraum?
pub fn is_empty(&self) -> bool {
self.text.content.trim().is_empty()
}
/// Ergebnis als Textfeld mit der ID `id`; die Höhe passt sich dem Text an, Lage,
/// Drehung und Skalierung bleiben. `None` für ein leeres Feld.
pub fn to_stroke(&self, id: u64) -> Option<Stroke> {
if self.is_empty() {
return None;
}
let corners = frame_corners(self.origin, self.axes, self.text.box_size());
Some(Stroke::new_text(id, self.text.clone(), corners, self.color))
}
}
/// Exponent für die Kantendeckung der Glyphen (wie egui, damit Bearbeitung und Anzeige
/// gleich kräftig wirken).
const COVERAGE_GAMMA: f32 = 0.55;
/// Größte Kantenlänge einer Texttextur in Pixeln.
const MAX_TEXTURE_SIDE: f32 = 4096.0;
/// Größte Pixelzahl einer Texttextur (64 MB RGBA).
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
let layout = text.layout();
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
let scale = px_per_unit
.min(MAX_TEXTURE_SIDE / size.x)
.min(MAX_TEXTURE_SIDE / size.y)
.min((MAX_TEXTURE_PIXELS / (size.x * size.y)).sqrt())
.max(1e-3);
let width = ((size.x * scale).ceil() as u32).max(1);
let height = ((size.y * scale).ceil() as u32).max(1);
let face = text.font.face();
// ab_glyph misst die Skalierung als Höhe Ober- bis Unterlänge
let px_scale = PxScale::from(text.size * scale * text.font.height_per_em());
let mut coverage = vec![0.0_f32; (width * height) as usize];
for (i, line) in layout.lines.iter().enumerate() {
let baseline = layout.baseline(i) * scale;
for &(id, x) in &line.glyphs {
let glyph = id.with_scale_and_position(px_scale, ab_glyph::point(x * scale, baseline));
let Some(outlined) = face.outline_glyph(glyph) else {
continue;
};
let bounds = outlined.px_bounds();
outlined.draw(|gx, gy, c| {
let px = bounds.min.x as i64 + gx as i64;
let py = bounds.min.y as i64 + gy as i64;
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
let cell = &mut coverage[(py as u32 * width + px as u32) as usize];
*cell = (*cell + c).min(1.0);
}
});
}
}
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
let alpha = color[3].clamp(0.0, 1.0);
let mut pixels = Vec::with_capacity(coverage.len() * 4);
for c in coverage {
pixels.extend_from_slice(&rgb);
// Wie egui: Deckung anheben, sonst wirkt die dünne Schrift verkleinert blass
pixels.push((c.powf(COVERAGE_GAMMA) * alpha * 255.0).round() as u8);
}
RgbaImage {
width,
height,
pixels,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::PAGE_WIDTH;
fn sample(content: &str, wrap_width: f32) -> TextBox {
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
}
#[test]
fn test_fonts_load() {
for font in [TextFont::Sans, TextFont::Mono] {
assert!(font.face().glyph_id('ä').0 != 0);
let factor = font.height_per_em();
assert!((1.0..1.4).contains(&factor), "{factor}");
}
}
#[test]
fn test_layout_wraps_at_spaces() {
let text = sample("eins zwei drei vier fünf sechs", 400.0);
let layout = text.layout();
assert!(layout.lines.len() > 1);
for line in &layout.lines {
assert!(line.width <= 400.0 + 1e-3, "{line:?}");
assert!(!line.text.starts_with(' ') && !line.text.ends_with(' '));
}
let words: Vec<String> = layout.lines.iter().map(|l| l.text.clone()).collect();
assert_eq!(words.join(" "), "eins zwei drei vier fünf sechs");
}
#[test]
fn test_layout_breaks_long_words_and_keeps_newlines() {
let text = sample("Donaudampfschifffahrtsgesellschaft\n\nende", 300.0);
let layout = text.layout();
assert!(layout.lines.iter().all(|l| l.width <= 300.0 + 1e-3));
let joined: String = layout.lines.iter().map(|l| l.text.as_str()).collect();
assert_eq!(joined, "Donaudampfschifffahrtsgesellschaftende");
// Leere Zeile bleibt erhalten
assert!(layout.lines.iter().any(|l| l.text.is_empty()));
assert_eq!(layout.lines.last().unwrap().text, "ende");
}
#[test]
fn test_empty_text_is_one_line_high() {
let layout = sample("", 500.0).layout();
assert!((layout.height() - layout.line_height).abs() < 1e-3);
assert!(layout.line_height > 50.0);
}
#[test]
fn test_tabs_become_spaces_and_indent_is_kept() {
let text = TextBox::new("\tx".into(), 50.0, TextFont::Mono, 2000.0);
let line = &text.layout().lines[0];
assert_eq!(line.text, " x");
// Festbreitenschrift: x steht vier Zeichenbreiten weiter
let advance = line.glyphs[1].1;
assert!((line.glyphs[4].1 - 4.0 * advance).abs() < 1e-3);
}
#[test]
fn test_frame_round_trip() {
let size = glam::Vec2::new(400.0, 120.0);
let axes = [glam::Vec2::new(0.0, 2.0), glam::Vec2::new(-2.0, 0.0)];
let corners = frame_corners(glam::Vec2::new(100.0, 50.0), axes, size);
let (origin, back) = frame_axes(&corners, size);
assert_eq!(origin, glam::Vec2::new(100.0, 50.0));
assert!((back[0] - axes[0]).length() < 1e-4 && (back[1] - axes[1]).length() < 1e-4);
}
#[test]
fn test_rasterize_draws_ink_in_color() {
let text = sample("Hallo", 400.0);
let image = rasterize(&text, [0.85, 0.2, 0.2, 1.0], 1.0);
assert_eq!(image.width, 400);
assert_eq!(image.height as f32, text.layout().height().ceil());
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
assert!(opaque > 100, "{opaque}");
// Farbe ist sRGB-kodiert
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
}
#[test]
fn test_editing_new_text_box() {
let edit = TextEditing::new_at(
1,
glam::Vec2::new(500.0, 800.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
);
assert!((edit.text.wrap_width - (PAGE_WIDTH - RIGHT_MARGIN - 500.0)).abs() < 1e-3);
assert!(edit.origin.y < 800.0);
assert!(!edit.is_changed());
assert!(edit.to_stroke(9).is_none(), "leeres Feld wird verworfen");
// Ganz rechts: Feld wird nach links versetzt, damit es mindestens 3 cm breit ist
let edge = TextEditing::new_at(
1,
glam::Vec2::new(2050.0, 100.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
);
assert!((edge.text.wrap_width - MIN_WRAP_WIDTH).abs() < 1e-3);
assert!(edge.origin.x + MIN_WRAP_WIDTH <= PAGE_WIDTH);
}
#[test]
fn test_editing_keeps_transform_and_grows_with_text() {
let mut edit = TextEditing::new_at(
1,
glam::Vec2::new(400.0, 400.0),
PAGE_WIDTH,
50.0,
TextFont::Sans,
[0.0; 4],
);
edit.text.content = "eine Zeile".into();
let mut stroke = edit.to_stroke(5).unwrap();
assert!(stroke.is_text() && stroke.is_object());
stroke.rotate(glam::Vec2::new(400.0, 400.0), 0.5);
stroke.scale(glam::Vec2::new(400.0, 400.0), glam::Vec2::new(2.0, 2.0));
let before = stroke.object_corners().unwrap();
let mut edit = TextEditing::of_stroke(1, &stroke).unwrap();
assert!(!edit.is_changed());
edit.text.content = "eine Zeile\nzwei Zeilen".into();
assert!(edit.is_changed());
let after = edit.to_stroke(5).unwrap().object_corners().unwrap();
// Obere Kante bleibt, die Seitenkanten werden in ihrer Richtung doppelt so lang
assert!((after[0] - before[0]).length() < 1e-2 && (after[1] - before[1]).length() < 1e-2);
let (old_side, new_side) = (before[3] - before[0], after[3] - after[0]);
assert!((new_side.length() / old_side.length() - 2.0).abs() < 1e-3);
assert!(old_side.normalize().dot(new_side.normalize()) > 0.9999);
}
#[test]
fn test_rasterize_limits_texture_size() {
let text = sample("x", 2000.0);
let image = rasterize(&text, [0.0, 0.0, 0.0, 1.0], 100.0);
assert!(image.width as f32 <= MAX_TEXTURE_SIDE);
}
}
+1489 -49
View File
File diff suppressed because it is too large Load Diff
+233 -9
View File
@@ -3,12 +3,99 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
use crate::document::{PageViewport, UNITS_PER_MM};
use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25;
pub const MAX_ZOOM: f32 = 5.0;
pub const MAX_ZOOM: f32 = 10.0;
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// aktuellen Seite.
#[derive(Debug, Clone, PartialEq)]
pub struct PageStack {
pub origins: Vec<Vec2>,
pub sizes: Vec<Vec2>,
pub current: usize,
}
impl PageStack {
/// Stapel aus den Seitengrößen; `current` ist die Bezugsseite.
pub fn new(sizes: Vec<Vec2>, current: usize) -> Self {
let current = current.min(sizes.len().saturating_sub(1));
let width = sizes.get(current).map_or(0.0, |s| s.x);
let mut origins = vec![Vec2::ZERO; sizes.len()];
let x = |i: usize| (width - sizes[i].x) / 2.0;
for i in current + 1..sizes.len() {
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
origins[i] = Vec2::new(x(i), y);
}
for i in (0..current).rev() {
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
origins[i] = Vec2::new(x(i), y);
}
Self {
origins,
sizes,
current,
}
}
pub fn len(&self) -> usize {
self.sizes.len()
}
pub fn is_empty(&self) -> bool {
self.sizes.is_empty()
}
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
pub fn page_at(&self, y: f32) -> Option<usize> {
// Seiten liegen nach Index sortiert untereinander
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
(y <= self.origins[i].y + self.sizes[i].y).then_some(i)
}
/// Seiten (außer der aktuellen), die zwischen den Höhen `top` und `bottom` sichtbar sind;
/// sehr weit herausgezoomt nur die [`MAX_NEIGHBOR_PAGES`] nächstgelegenen.
pub fn visible_neighbors(&self, top: f32, bottom: f32) -> Vec<usize> {
if !(top.is_finite() && bottom.is_finite()) || bottom < top {
return Vec::new();
}
let first = self
.origins
.iter()
.zip(&self.sizes)
.position(|(o, s)| o.y + s.y >= top);
let Some(first) = first else {
return Vec::new();
};
let mut pages: Vec<usize> = (first..self.len())
.take_while(|&i| self.origins[i].y <= bottom)
.filter(|&i| i != self.current)
.collect();
if pages.len() > MAX_NEIGHBOR_PAGES {
pages.sort_by_key(|&i| i.abs_diff(self.current));
pages.truncate(MAX_NEIGHBOR_PAGES);
pages.sort_unstable();
}
pages
}
/// Kleinstes Rechteck um alle Seiten (min, max).
pub fn bounds(&self) -> (Vec2, Vec2) {
self.origins.iter().zip(&self.sizes).fold(
(Vec2::splat(f32::INFINITY), Vec2::splat(f32::NEG_INFINITY)),
|(min, max), (&o, &s)| (min.min(o), max.max(o + s)),
)
}
}
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -39,6 +126,39 @@ impl ViewportState {
self.pan_offset = [0.0, 0.0];
}
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der bisherigen
/// Bezugsseite) wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Waagerecht
/// zentriert [`PageViewport`] die neue Seite bereits (ungezoomt); der Pan gleicht nur den
/// Zoomanteil aus.
pub fn shift_reference_page(&mut self, origin: Vec2) {
self.pan_offset[0] += origin.x * (1.0 - 1.0 / self.zoom.max(1e-6));
self.pan_offset[1] += origin.y;
}
/// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center`
/// über dem Notizbuch liegt (zwischen Oberkante der ersten und Unterkante der letzten
/// Seite, waagerecht innerhalb der Seitenbreite). `true`, wenn korrigiert wurde.
pub fn clamp_continuous_center(
&mut self,
center: [f32; 2],
stack: &PageStack,
page_viewport: &PageViewport,
) -> bool {
if stack.is_empty() {
return false;
}
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
let (min, max) = stack.bounds();
let clamped = c.clamp(min, max.max(min));
if (clamped - c).length() < 1e-3 {
return false;
}
// Bildschirm = Viewport + (Seite + Pan) · Maßstab: der Pan gleicht die Differenz aus
self.pan_offset[0] += c.x - clamped.x;
self.pan_offset[1] += c.y - clamped.y;
true
}
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta(
&mut self,
@@ -46,7 +166,7 @@ impl ViewportState {
delta_y: f32,
page_viewport: &PageViewport,
) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
if base_scale * self.zoom > 1e-6 {
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
self.pan_offset[1] += delta_y / (base_scale * self.zoom);
@@ -68,7 +188,7 @@ impl ViewportState {
return;
}
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
// Bildschirmposition des Seitenursprungs (0, 0)
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom;
@@ -90,7 +210,7 @@ impl ViewportState {
screen_y: f32,
page_viewport: &PageViewport,
) -> Vec2 {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
if effective_scale < 1e-6 {
return Vec2::ZERO;
@@ -112,7 +232,8 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Option<Vec2> {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) {
let size = page_viewport.page_size;
if (0.0..=size.x).contains(&pt.x) && (0.0..=size.y).contains(&pt.y) {
Some(pt)
} else {
None
@@ -127,7 +248,7 @@ impl ViewportState {
page_viewport: &PageViewport,
) -> Vec2 {
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
pt.clamp(Vec2::ZERO, page_viewport.page_size)
}
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
@@ -142,7 +263,7 @@ impl ViewportState {
page_y: f32,
page_viewport: &PageViewport,
) -> [f32; 2] {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale;
let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale;
@@ -152,7 +273,7 @@ impl ViewportState {
/// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader,
/// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) {
let base_scale = page_viewport.page_width / PAGE_WIDTH;
let base_scale = page_viewport.scale;
let effective_scale = base_scale * self.zoom;
// ndc_x = (screen_x / window_width) * 2.0 - 1.0
@@ -190,6 +311,109 @@ impl ViewportState {
mod tests {
use super::*;
const A4: Vec2 = crate::document::A4_SIZE;
const PITCH: f32 = A4.y + PAGE_GAP;
fn a4_stack(count: usize, current: usize) -> PageStack {
PageStack::new(vec![A4; count], current)
}
#[test]
fn test_continuous_layout() {
let stack = a4_stack(5, 1);
assert_eq!(stack.page_at(100.0), Some(1));
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
assert_eq!(stack.page_at(-5.0), None);
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
assert_eq!(
stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0),
vec![2]
);
// Ganz oben im Notizbuch: keine Seite vor der ersten
assert!(a4_stack(5, 0)
.visible_neighbors(-PITCH * 3.0, 500.0)
.is_empty());
// Hinter der letzten Seite: keine weiteren
assert_eq!(
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
vec![2, 4]
);
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
let many = a4_stack(200, 50).visible_neighbors(-PITCH * 50.0, PITCH * 50.0);
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
assert!(many.iter().all(|i| i.abs_diff(50) <= 6));
}
#[test]
fn test_stack_of_different_page_sizes() {
let slide = Vec2::new(2970.0, 1671.0);
let stack = PageStack::new(vec![slide, A4, slide], 1);
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
let (min, max) = stack.bounds();
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
}
#[test]
fn test_shift_reference_page_keeps_view() {
let mut vp = ViewportState::new();
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
vp.pan_by_screen_delta(0.0, -900.0, &page_vp);
// Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite
let before = vp.page_to_screen(300.0, PITCH + 200.0, &page_vp);
vp.shift_reference_page(a4_stack(2, 0).origins[1]);
let after = vp.page_to_screen(300.0, 200.0, &page_vp);
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
}
#[test]
fn test_shift_to_page_of_other_size_keeps_view_when_zoomed() {
let slide = Vec2::new(2970.0, 1671.0);
let fit = Vec2::new(2970.0, 2970.0);
let view_a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4);
let view_slide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide);
let stack = PageStack::new(vec![A4, slide], 0);
let mut vp = ViewportState::new();
vp.zoom_at_cursor([500.0, 300.0], 2.5, &view_a4);
vp.pan_by_screen_delta(-40.0, -700.0, &view_a4);
let point = Vec2::new(900.0, 300.0);
let before = vp.page_to_screen_vec2(stack.origins[1] + point, &view_a4);
vp.shift_reference_page(stack.origins[1]);
let after = vp.page_to_screen_vec2(point, &view_slide);
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
}
#[test]
fn test_continuous_scroll_stays_on_the_notebook() {
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
let center = [640.0, 425.0];
let stack = a4_stack(3, 1);
let mut vp = ViewportState::new();
// Mitte der Seite: nichts zu korrigieren
assert!(!vp.clamp_continuous_center(center, &stack, &page_vp));
// Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3)
vp.pan_by_screen_delta(0.0, 5000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!(
(c.y + PITCH).abs() < 0.1,
"Oberkante der ersten Seite: {c:?}"
);
// Weit nach unten: Unterkante der letzten Seite
vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}");
assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
}
#[test]
fn test_viewport_zoom_limits() {
let mut vp = ViewportState::new();