Compare commits

..
21 Commits
Author SHA1 Message Date
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
26 changed files with 8270 additions and 399 deletions
Generated
+711 -19
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,12 +1,12 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.3.4" version = "0.9.0"
edition = "2021" edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux" description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
rust-version = "1.75" rust-version = "1.92"
[dependencies] [dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus) # Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -39,8 +39,14 @@ sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5" url = "2.5"
octotablet = "0.1.0" 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" minisign-verify = "0.3.0"
fs2 = "0.4" fs2 = "0.4"
hayro = "0.8"
hayro-svg = "0.8"
[profile.dev] [profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -53,3 +59,5 @@ strip = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.27.0" 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"] }
+4 -2
View File
@@ -21,7 +21,8 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
### 🔍 Stufenloser Zoom & Pan (Kamera) ### 🔍 Stufenloser Zoom & Pan (Kamera)
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader. - **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 500 %** 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. - **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`). - **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
@@ -219,7 +220,8 @@ cargo run --release
| :--- | :--- | | :--- | :--- |
| **Zeichnen** | Linke Maustaste / Stiftspitze | | **Zeichnen** | Linke Maustaste / Stiftspitze |
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` | | **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 zurücksetzen (100 %)** | `Strg + 0` |
| **Zoom In / Out** | `Strg + +` / `Strg + -` | | **Zoom In / Out** | `Strg + +` / `Strg + -` |
| **Auswahlwerkzeug (Lasso)** | `V` | | **Auswahlwerkzeug (Lasso)** | `V` |
+14 -1
View File
@@ -3,6 +3,19 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04). Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **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) - **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 - **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern - **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
@@ -10,7 +23,7 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung - **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 - **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) - **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Dreiecke, Rauten, Vielecke & Koordinatensysteme (Draw-and-Hold); Rechtecke werden zuverlässig erkannt, Freiformen bleiben unverändert - **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 - **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 - **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 - **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")));
}
}
+195 -36
View File
@@ -1,5 +1,7 @@
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko). //! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex; use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
@@ -13,43 +15,122 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten). /// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0; pub const LINED_SPACING: f32 = 75.0;
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0). /// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0]; /// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF). ///
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; /// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub const DESK_COLOR: wgpu::Color = wgpu::Color { #[serde(rename_all = "lowercase")]
r: 0.16, pub enum PaperTheme {
g: 0.16, #[default]
b: 0.18, White,
a: 1.0, /// 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. impl PaperTheme {
pub fn generate_paper_quad() -> Vec<InkVertex> { pub const ALL: [PaperTheme; 4] = [
PaperTheme::White,
PaperTheme::Cream,
PaperTheme::DarkGray,
PaperTheme::Black,
];
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => "Weiß",
PaperTheme::Cream => "Creme",
PaperTheme::DarkGray => "Dunkelgrau",
PaperTheme::Black => "Schwarz",
}
}
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
pub fn is_dark(self) -> bool {
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
}
/// Farbe des Blatts.
pub fn paper_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
// sRGB #FBF5E6
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
// sRGB #2B2B2E
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
}
}
/// Farbe von Karos, Linien und Punkten.
pub fn pattern_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
// sRGB #48484D bzw. #38383C
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
}
}
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
/// das Blatt abhebt.
pub fn desk_color(self) -> wgpu::Color {
let (r, g, b) = match self {
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
wgpu::Color { r, g, b, a: 1.0 }
}
}
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
let max = color[0].max(color[1]).max(color[2]);
let min = color[0].min(color[1]).min(color[2]);
let lift = (1.0 - max - min).max(0.0);
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
vec![ vec![
InkVertex::new(0.0, 0.0, PAPER_COLOR), InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
] ]
} }
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. /// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline] #[inline]
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) { fn push_quad(
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR)); vertices: &mut Vec<InkVertex>,
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); color: [f32; 4],
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); x_min: f32,
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); y_min: f32,
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); x_max: f32,
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR)); 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. /// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> { pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
match background { match background {
PageBackground::Blank => Vec::new(), PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => { PageBackground::Grid5mm => {
@@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
// Vertikale Linien alle 5 mm // Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING; let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH { while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT); push_quad(
&mut vertices,
color,
x - half_t,
0.0,
x + half_t,
PAGE_HEIGHT,
);
x += GRID_5MM_SPACING; x += GRID_5MM_SPACING;
} }
// Horizontale Linien alle 5 mm // Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING; let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT { while y < PAGE_HEIGHT {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += GRID_5MM_SPACING; y += GRID_5MM_SPACING;
} }
@@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
let mut y = margin_top; let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom { while y <= PAGE_HEIGHT - margin_bottom {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += LINED_SPACING; y += LINED_SPACING;
} }
@@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
while x < PAGE_WIDTH { while x < PAGE_WIDTH {
push_quad( push_quad(
&mut vertices, &mut vertices,
color,
x - half_d, x - half_d,
y - half_d, y - half_d,
x + half_d, x + half_d,
@@ -119,18 +222,20 @@ mod tests {
#[test] #[test]
fn test_paper_quad_generation() { fn test_paper_quad_generation() {
let quad = generate_paper_quad(); let quad = generate_paper_quad(PaperTheme::White.paper_color());
assert_eq!(quad.len(), 6); assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]); assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]); assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
} }
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test] #[test]
fn test_background_meshes() { fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank); let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
assert!(blank.is_empty()); assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm); let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
assert!(!grid.is_empty()); assert!(!grid.is_empty());
assert_eq!( assert_eq!(
grid.len() % 6, grid.len() % 6,
@@ -138,12 +243,66 @@ mod tests {
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
); );
let lined = generate_background_mesh(PageBackground::Lined); let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
assert!(!lined.is_empty()); assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0); assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid); let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
assert!(!dots.is_empty()); assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0); assert_eq!(dots.len() % 6, 0);
} }
#[test]
fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
}
#[test]
fn test_adapt_ink_color() {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
// Schwarz wird Weiß, Deckkraft bleibt
assert!(close(
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
[1.0, 1.0, 1.0, 0.7]
));
// Notiz-Schwarz wird hell
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
assert!(ink[0] > 0.85 && ink[2] > 0.85);
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
for c in [
[0.85, 0.20, 0.20, 1.0],
[0.15, 0.35, 0.85, 1.0],
[1.0, 0.95, 0.1, 0.35],
[1.0, 1.0, 1.0, 1.0],
] {
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
}
// Ergebnis ist nie heller als Weiß
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
}
}
#[test]
fn test_paper_themes_contrast() {
for theme in PaperTheme::ALL {
let paper = theme.paper_color()[0];
let pattern = theme.pattern_color()[0];
assert!(
(paper - pattern).abs() > 0.03,
"{theme:?}: Muster hebt sich nicht vom Papier ab"
);
// Helles Papier ↔ dunkles Papier
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
}
}
} }
+455 -48
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize; use winit::dpi::PhysicalSize;
use winit::window::Window; 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::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter; use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig}; use crate::spline::{self, ControlPoint, SplineConfig};
@@ -58,6 +58,8 @@ impl InkVertex {
pub struct CameraUniforms { pub struct CameraUniforms {
pub transform_scale: [f32; 2], pub transform_scale: [f32; 2],
pub transform_offset: [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]. /// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
@@ -87,8 +89,73 @@ pub fn spline_vertices_to_ndc(
} }
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking. /// 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,
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,
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,
/// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber)
offset: i64,
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 struct CanvasState {
pub surface: wgpu::Surface<'static>, 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 device: wgpu::Device,
pub queue: wgpu::Queue, pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration, pub config: wgpu::SurfaceConfiguration,
@@ -108,7 +175,6 @@ pub struct CanvasState {
pub camera_bind_group: wgpu::BindGroup, pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4], pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32, pub current_stroke_width_mm: f32,
pub current_tool: crate::document::StrokeTool,
// Statische Hintergrund-Geometrie // Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer, pub paper_buffer: wgpu::Buffer,
@@ -116,6 +182,10 @@ pub struct CanvasState {
pub background_vertex_capacity: usize, pub background_vertex_capacity: usize,
pub background_vertex_count: u32, pub background_vertex_count: u32,
pub current_background: PageBackground, pub current_background: PageBackground,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen
pub adapt_ink: bool,
// Pipeline-Trennung: Live-Strich (aktiver Stroke) // Pipeline-Trennung: Live-Strich (aktiver Stroke)
pub live_vertex_buffer: wgpu::Buffer, pub live_vertex_buffer: wgpu::Buffer,
@@ -133,6 +203,18 @@ pub struct CanvasState {
pub committed_vertex_count: u32, pub committed_vertex_count: u32,
pub committed_strokes: Vec<Range<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 // egui UI-Renderer
pub egui_renderer: egui_wgpu::Renderer, pub egui_renderer: egui_wgpu::Renderer,
} }
@@ -149,23 +231,47 @@ impl CanvasState {
let viewport = let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN); 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 { 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() ..Default::default()
}); });
let surface = instance.create_surface(window.clone())?; let surface = instance.create_surface(window.clone())?;
// 2. Adapter auswählen (hohe Performance bevorzugt) // 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
let adapter = instance // Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
.request_adapter(&wgpu::RequestAdapterOptions { // anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
power_preference: wgpu::PowerPreference::HighPerformance, let mut chosen = None;
compatible_surface: Some(&surface), for force_fallback_adapter in [false, true] {
force_fallback_adapter: false, let Some(adapter) = instance
}) .request_adapter(&wgpu::RequestAdapterOptions {
.await power_preference: wgpu::PowerPreference::HighPerformance,
.ok_or("Kein passender GPU-Adapter gefunden")?; 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 // 3. Device & Queue erstellen
let (device, queue) = adapter let (device, queue) = adapter
@@ -179,8 +285,7 @@ impl CanvasState {
) )
.await?; .await?;
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1 // 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
.iter() .iter()
@@ -188,20 +293,6 @@ impl CanvasState {
.find(|f| f.is_srgb()) .find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]); .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 { let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format, format: surface_format,
@@ -240,6 +331,7 @@ impl CanvasState {
let camera_uniforms = CameraUniforms { let camera_uniforms = CameraUniforms {
transform_scale: scale, transform_scale: scale,
transform_offset: offset, transform_offset: offset,
ink_params: [0.0; 4],
}; };
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor { let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Camera Uniform Buffer"), label: Some("Camera Uniform Buffer"),
@@ -276,7 +368,7 @@ impl CanvasState {
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
module: &shader, module: &shader,
entry_point: "fs_main", entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -330,7 +422,8 @@ impl CanvasState {
}); });
// 6. Statische Buffer // 6. Statische Buffer
let paper_quad = generate_paper_quad(); let paper_theme = PaperTheme::default();
let paper_quad = generate_paper_quad(paper_theme.paper_color());
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor { let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Paper Quad Buffer"), label: Some("Paper Quad Buffer"),
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64, size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
@@ -340,7 +433,7 @@ impl CanvasState {
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
let current_background = PageBackground::Grid5mm; let current_background = PageBackground::Grid5mm;
let bg_mesh = generate_background_mesh(current_background); let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer"), label: Some("Background Mesh Buffer"),
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64, size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
@@ -364,11 +457,20 @@ impl CanvasState {
mapped_at_creation: false, 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 // 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
Ok(Self { Ok(Self {
surface, surface,
adapter,
presentation_warned: false,
device, device,
queue, queue,
config, config,
@@ -382,12 +484,13 @@ impl CanvasState {
camera_bind_group, camera_bind_group,
current_ink_color: default_ink_color, current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5, current_stroke_width_mm: 0.5,
current_tool: crate::document::StrokeTool::Pen,
paper_buffer, paper_buffer,
background_buffer, background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY, background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
background_vertex_count: bg_mesh.len() as u32, background_vertex_count: bg_mesh.len() as u32,
current_background, current_background,
paper_theme,
adapt_ink: false,
live_vertex_buffer, live_vertex_buffer,
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY, live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
live_vertex_count: 0, live_vertex_count: 0,
@@ -404,6 +507,11 @@ impl CanvasState {
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY, committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0, committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128), committed_strokes: Vec::with_capacity(128),
image_layer,
neighbors: Vec::new(),
camera_layout: bind_group_layout,
ruler: None,
ruler_lock: None,
egui_renderer, egui_renderer,
camera: crate::viewport::ViewportState::new(), camera: crate::viewport::ViewportState::new(),
}) })
@@ -411,16 +519,39 @@ impl CanvasState {
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera. /// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
pub fn update_uniforms(&mut self) { pub fn update_uniforms(&mut self) {
let (scale, offset) = self.camera.ndc_transform(&self.viewport); let camera_uniforms = self.camera_uniforms(0);
let camera_uniforms = CameraUniforms {
transform_scale: scale,
transform_offset: offset,
};
self.queue.write_buffer( self.queue.write_buffer(
&self.camera_buffer, &self.camera_buffer,
0, 0,
bytemuck::cast_slice(&[camera_uniforms]), 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 die Seite `page_offset` Seiten unter (negativ: über) der aktuellen.
fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms {
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * 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 /// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
@@ -452,6 +583,23 @@ impl CanvasState {
self.size = new_size; self.size = new_size;
self.config.width = new_size.width; self.config.width = new_size.width;
self.config.height = new_size.height; 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.surface.configure(&self.device, &self.config);
self.recalculate_viewport(); self.recalculate_viewport();
} }
@@ -494,6 +642,63 @@ impl CanvasState {
self.camera.page_to_screen_vec2(page_pt, &self.viewport) self.camera.page_to_screen_vec2(page_pt, &self.viewport)
} }
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt,
/// `None` neben den Seiten und zwischen zwei Seiten.
pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option<i64> {
let p = self
.camera
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) {
return None;
}
crate::viewport::page_offset_at(p.y)
}
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters.
pub fn page_offset_at_view_center(&self) -> Option<i64> {
let p = self.camera.screen_to_page_unclamped(
0.0,
self.size.height as f32 / 2.0,
&self.viewport,
);
crate::viewport::page_offset_at(p.y)
}
/// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite.
pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec<i64> {
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);
crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count)
}
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> 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, current, page_count, &self.viewport);
if changed {
self.update_uniforms();
}
changed
}
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
/// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen.
pub fn shift_reference_page(&mut self, k: i64) {
self.camera.shift_reference_page(k);
self.update_uniforms();
}
/// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold). /// 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>) { pub fn set_live_shape_mesh(&mut self, samples: Vec<StylusSample>, vertices: Vec<InkVertex>) {
self.live_samples = samples; self.live_samples = samples;
@@ -532,9 +737,19 @@ impl CanvasState {
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM); SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
} }
/// Setzt das aktive Zeichenwerkzeug für den Stift. /// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
pub fn set_tool(&mut self, tool: crate::document::StrokeTool) { pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
self.current_tool = tool; self.adapt_ink = adapt_ink;
if self.paper_theme != theme {
self.paper_theme = theme;
self.queue.write_buffer(
&self.paper_buffer,
0,
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
);
self.upload_background_mesh();
}
self.update_uniforms();
} }
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -543,7 +758,13 @@ impl CanvasState {
return; return;
} }
self.current_background = background; self.current_background = background;
let mesh = generate_background_mesh(background); self.upload_background_mesh();
}
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
fn upload_background_mesh(&mut self) {
let mesh =
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
self.background_vertex_count = mesh.len() as u32; self.background_vertex_count = mesh.len() as u32;
if mesh.len() > self.background_vertex_capacity { if mesh.len() > self.background_vertex_capacity {
@@ -565,7 +786,10 @@ impl CanvasState {
/// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite. /// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite.
pub fn sync_page(&mut self, page: &mut Page) { pub fn sync_page(&mut self, page: &mut Page) {
page.rebuild_cache_if_dirty(); 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);
self.set_background(page.background); self.set_background(page.background);
self.image_layer.set_quads(page_image_quads(page));
self.committed_vertex_count = page.vertex_cache.len() as u32; self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone(); self.committed_strokes = page.stroke_ranges.clone();
@@ -592,6 +816,88 @@ 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: &[(i64, &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(page_image_quads(page));
}
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
})
};
let background =
generate_background_mesh(page.background, self.paper_theme.pattern_color());
NeighborPage {
fingerprint,
offset: 0,
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,
) {
self.image_layer.prepare(&self.device, &self.queue, load);
}
/// 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. /// 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) { pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
self.filter.reset(); self.filter.reset();
@@ -600,6 +906,13 @@ impl CanvasState {
self.live_vertices.clear(); self.live_vertices.clear();
self.live_vertex_count = 0; 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 // 1. Zwingend durch den 1€-Filter leiten
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
let pt = ControlPoint::new(fx, fy, fp); let pt = ControlPoint::new(fx, fy, fp);
@@ -623,6 +936,7 @@ impl CanvasState {
} }
// 1. Zwingend durch den 1€-Filter leiten // 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 (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
let new_pt = ControlPoint::new(fx, fy, fp); let new_pt = ControlPoint::new(fx, fy, fp);
@@ -645,6 +959,19 @@ impl CanvasState {
self.update_live_mesh(); 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. /// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
pub fn cancel_stroke(&mut self) { pub fn cancel_stroke(&mut self) {
self.live_samples.clear(); self.live_samples.clear();
@@ -673,7 +1000,6 @@ impl CanvasState {
&self.spline_config, &self.spline_config,
); );
let mut stroke = stroke; let mut stroke = stroke;
stroke.tool = self.current_tool;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM; stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true; stroke.finalized = true;
@@ -769,7 +1095,7 @@ impl CanvasState {
view: &view, view: &view,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { 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, store: wgpu::StoreOp::Store,
}, },
})], })],
@@ -778,10 +1104,32 @@ impl CanvasState {
occlusion_query_set: None, 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);
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
render_pass.draw(0..6, 0..1);
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 // 2D-Kamera Uniforms (Scale & Offset) binden
render_pass.set_bind_group(0, &self.camera_bind_group, &[]); 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_pipeline(&self.triangle_pipeline);
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
render_pass.draw(0..6, 0..1); render_pass.draw(0..6, 0..1);
@@ -792,7 +1140,10 @@ impl CanvasState {
render_pass.draw(0..self.background_vertex_count, 0..1); 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); render_pass.set_pipeline(&self.strip_pipeline);
if self.committed_vertex_count > 0 { if self.committed_vertex_count > 0 {
@@ -804,7 +1155,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 { if self.live_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
render_pass.draw(0..self.live_vertex_count, 0..1); render_pass.draw(0..self.live_vertex_count, 0..1);
@@ -854,10 +1205,33 @@ impl CanvasState {
} }
} }
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten Bilder.
fn page_image_quads(page: &Page) -> Vec<(String, [glam::Vec2; 4])> {
page.pdf_background
.iter()
.map(|pdf| (pdf.key(), pdf.corners()))
.chain(
page.strokes
.iter()
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
)
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_choose_present_mode() {
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
assert_eq!(choose_present_mode(&[]), None);
}
#[test] #[test]
fn test_pixel_to_ndc_mapping() { fn test_pixel_to_ndc_mapping() {
let width = 800.0; let width = 800.0;
@@ -883,7 +1257,40 @@ mod tests {
#[test] #[test]
fn test_camera_uniforms() { fn test_camera_uniforms() {
let _u = CameraUniforms::default(); 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] #[test]
+739 -11
View File
@@ -103,6 +103,109 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length() (p - projection).length()
} }
/// Liegt `p` im konvexen Viereck `quad` (Ecken der Reihe nach, beliebiger Umlaufsinn)?
pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
let mut sign = 0.0_f32;
for i in 0..4 {
let a = quad[i];
let b = quad[(i + 1) % 4];
let cross = (b - a).perp_dot(p - a);
if cross.abs() < 1e-6 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
true
}
/// Größte Länge eines Seitentitels in Zeichen.
pub const MAX_PAGE_TITLE_CHARS: usize = 120;
/// Bereinigt einen Seitentitel: Zeilenumbrüche und doppelte Leerzeichen entfernen, auf
/// [`MAX_PAGE_TITLE_CHARS`] kürzen; leer bedeutet „kein Titel“.
pub fn normalize_page_title(title: &str) -> Option<String> {
let cleaned: String = title
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(MAX_PAGE_TITLE_CHARS)
.collect();
(!cleaned.is_empty()).then_some(cleaned)
}
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PdfBackground {
/// Name der PDF-Datei im Asset-Ordner
pub asset: String,
/// Seitennummer im PDF, ab 0
pub page: u32,
/// Angezeigte Größe der PDF-Seite in Punkt (Drehung berücksichtigt)
pub width_pt: f32,
pub height_pt: f32,
}
impl PdfBackground {
/// Schlüssel für Texturen und Render-Aufträge.
pub fn key(&self) -> String {
format!("{}#{}", self.asset, self.page)
}
/// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf dem
/// A4-Blatt, siehe [`pdf_placement`].
pub fn corners(&self) -> [glam::Vec2; 4] {
pdf_placement(self.width_pt, self.height_pt)
}
}
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf dem A4-Blatt: so groß wie möglich,
/// waagerecht zentriert und oben bündig. Querformat-Folien liegen so oben auf der Seite,
/// darunter bleibt Platz für Notizen.
pub fn pdf_placement(width_pt: f32, height_pt: f32) -> [glam::Vec2; 4] {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h);
let size = glam::Vec2::new(w * scale, h * scale);
let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0);
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
tl + size,
tl + glam::Vec2::new(0.0, size.y),
]
}
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt.
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] {
let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (PAGE_WIDTH * 0.8 / natural.x)
.min(PAGE_HEIGHT * 0.6 / natural.y)
.min(1.0);
let size = natural * fit;
let half = size / 2.0;
let center = glam::Vec2::new(
center.x.clamp(half.x, PAGE_WIDTH - half.x),
center.y.clamp(half.y, PAGE_HEIGHT - half.y),
);
let tl = center - half;
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
tl + size,
tl + glam::Vec2::new(0.0, size.y),
]
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde. /// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -113,6 +216,145 @@ pub enum StrokeTool {
Shape, Shape,
} }
impl StrokeTool {
/// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und
/// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der
/// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form.
pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool {
match stored {
StrokeTool::Pen if has_shape => StrokeTool::Shape,
StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter,
other => other,
}
}
}
/// Welche Striche der Radierer entfernt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EraserMode {
/// Alle getroffenen Striche
#[default]
All,
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
HighlighterOnly,
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
/// erhalten. Formen werden als Ganzes radiert.
Partial,
}
impl EraserMode {
/// Darf ein Strich dieses Werkzeugs radiert werden?
pub fn erases(self, tool: StrokeTool) -> bool {
match self {
EraserMode::All | EraserMode::Partial => true,
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
}
}
}
/// Zeitfenster, in dem aufeinanderfolgende Teilradierungen zu einem Undo-Schritt
/// zusammengefasst werden (ein Radierzug besteht aus vielen kleinen Schnitten).
pub const PARTIAL_ERASE_MERGE_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
/// Zerlegt die Punktfolge eines Strichs an einem Kreis (Radierer): übrig bleiben die Stücke
/// außerhalb des Kreises, an den Kreisrand gekürzt. `None`, wenn der Kreis den Strich nicht
/// berührt. Stücke kürzer als `min_length` entfallen.
pub fn split_samples_at_circle(
samples: &[StylusSample],
center: glam::Vec2,
radius: f32,
min_length: f32,
) -> Option<Vec<Vec<StylusSample>>> {
let inside = |p: glam::Vec2| (p - center).length_squared() <= radius * radius;
let lerp = |a: &StylusSample, b: &StylusSample, t: f32| StylusSample {
position: a.position.lerp(b.position, t),
pressure: a.pressure + (b.pressure - a.pressure) * t,
timestamp: a.timestamp + (b.timestamp - a.timestamp) * t as f64,
};
// Schnittparameter t ∈ (0, 1) einer Strecke mit dem Kreisrand, aufsteigend
let crossings = |a: glam::Vec2, b: glam::Vec2| -> Vec<f32> {
let d = b - a;
let f = a - center;
let qa = d.dot(d);
if qa < 1e-12 {
return Vec::new();
}
let qb = 2.0 * f.dot(d);
let qc = f.dot(f) - radius * radius;
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
return Vec::new();
}
let root = disc.sqrt();
[(-qb - root) / (2.0 * qa), (-qb + root) / (2.0 * qa)]
.into_iter()
.filter(|t| *t > 0.0 && *t < 1.0)
.collect()
};
match samples {
[] => return None,
[single] => return inside(single.position).then(Vec::new),
_ => {}
}
let touched = samples.iter().any(|s| inside(s.position))
|| samples
.windows(2)
.any(|w| crossings(w[0].position, w[1].position).len() == 2);
if !touched {
return None;
}
let mut pieces = Vec::new();
let mut current: Vec<StylusSample> = Vec::new();
if !inside(samples[0].position) {
current.push(samples[0]);
}
for w in samples.windows(2) {
let (a, b) = (&w[0], &w[1]);
let (a_in, b_in) = (inside(a.position), inside(b.position));
let ts = crossings(a.position, b.position);
match (a_in, b_in) {
(false, false) => {
if let [t_enter, t_exit] = ts[..] {
// Strecke läuft durch den Kreis
current.push(lerp(a, b, t_enter));
pieces.push(std::mem::take(&mut current));
current.push(lerp(a, b, t_exit));
}
current.push(*b);
}
(false, true) => {
if let Some(&t) = ts.first() {
current.push(lerp(a, b, t));
}
pieces.push(std::mem::take(&mut current));
}
(true, false) => {
current.clear();
if let Some(&t) = ts.last() {
current.push(lerp(a, b, t));
}
current.push(*b);
}
(true, true) => {}
}
}
pieces.push(current);
let length = |p: &Vec<StylusSample>| -> f32 {
p.windows(2)
.map(|w| (w[1].position - w[0].position).length())
.sum()
};
Some(
pieces
.into_iter()
.filter(|p| p.len() >= 2 && length(p) >= min_length)
.collect(),
)
}
/// Ein einzelner Vektor-Strich auf einer Seite. /// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Stroke { pub struct Stroke {
@@ -125,6 +367,10 @@ pub struct Stroke {
pub tool: StrokeTool, pub tool: StrokeTool,
pub width: f32, pub width: f32,
pub finalized: bool, pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner, siehe `assets`). Die vier Punkte sind dann die
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
pub image: Option<String>,
} }
impl Stroke { impl Stroke {
@@ -157,9 +403,44 @@ impl Stroke {
tool: StrokeTool::Pen, tool: StrokeTool::Pen,
width: 5.0, width: 5.0,
finalized: true, finalized: true,
image: None,
} }
} }
/// Erzeugt ein Bildobjekt mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn new_image(id: u64, asset: String, corners: [glam::Vec2; 4]) -> Self {
let points = corners
.iter()
.map(|&position| StylusSample {
position,
pressure: 1.0,
timestamp: 0.0,
})
.collect();
let mut stroke = Self::new(id, points, Vec::new(), [1.0, 1.0, 1.0, 1.0]);
stroke.width = 0.0;
stroke.image = Some(asset);
stroke
}
/// Ist dieses Objekt ein Bild?
pub fn is_image(&self) -> bool {
self.image.is_some()
}
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
if self.image.is_none() || self.points.len() < 4 {
return None;
}
Some([
self.points[0].position,
self.points[1].position,
self.points[2].position,
self.points[3].position,
])
}
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell). /// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
pub fn finalize( pub fn finalize(
id: u64, id: u64,
@@ -193,7 +474,16 @@ impl Stroke {
} }
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich. /// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 { pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
if let Some(corners) = self.image_corners() {
if point_in_convex_quad(p, &corners) {
return 0.0;
}
return (0..4)
.map(|i| point_to_segment_distance(p, corners[i], corners[(i + 1) % 4]))
.fold(f32::INFINITY, f32::min);
}
if self.points.is_empty() { if self.points.is_empty() {
return f32::INFINITY; return f32::INFINITY;
} }
@@ -315,7 +605,11 @@ impl Stroke {
} }
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben. /// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
/// Bilder behalten ihre Farben.
pub fn set_color(&mut self, new_color: [f32; 4]) { pub fn set_color(&mut self, new_color: [f32; 4]) {
if self.is_image() {
return;
}
self.color = new_color; self.color = new_color;
for v in &mut self.vertices { for v in &mut self.vertices {
v.color = new_color; v.color = new_color;
@@ -358,6 +652,10 @@ pub struct Page {
pub id: u64, pub id: u64,
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
pub background: PageBackground, pub background: PageBackground,
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
pub pdf_background: Option<PdfBackground>,
/// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist
pub title: Option<String>,
pub vertex_cache: Vec<InkVertex>, pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>, pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool, pub is_dirty: bool,
@@ -372,6 +670,8 @@ impl Page {
id, id,
strokes: Vec::new(), strokes: Vec::new(),
background, background,
pdf_background: None,
title: None,
vertex_cache: Vec::new(), vertex_cache: Vec::new(),
stroke_ranges: Vec::new(), stroke_ranges: Vec::new(),
is_dirty: false, is_dirty: false,
@@ -387,6 +687,8 @@ impl Page {
id, id,
strokes: Vec::new(), strokes: Vec::new(),
background, background,
pdf_background: None,
title: None,
vertex_cache: Vec::new(), vertex_cache: Vec::new(),
stroke_ranges: Vec::new(), stroke_ranges: Vec::new(),
is_dirty: false, is_dirty: false,
@@ -429,20 +731,18 @@ impl Page {
} }
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius. /// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> { pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
let mut best: Option<(&Stroke, f32)> = None; let mut best: Option<(&Stroke, (bool, f32))> = None;
for s in &self.strokes { for s in &self.strokes {
if !s.aabb.expanded(max_dist).contains_point(pos) { if !s.aabb.expanded(max_dist).contains_point(pos) {
continue; continue;
} }
let d = s.distance_to_point(pos); let d = s.distance_to_point(pos);
if d <= max_dist { if d <= max_dist {
if let Some((_, best_d)) = best { let key = (s.is_image(), d);
if d < best_d { if best.is_none_or(|(_, best_key)| key < best_key) {
best = Some((s, d)); best = Some((s, key));
}
} else {
best = Some((s, d));
} }
} }
} }
@@ -469,7 +769,8 @@ impl Page {
} }
} }
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`. /// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`,
/// die `mode` zulässt.
/// ///
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden). /// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
pub fn erase_at( pub fn erase_at(
@@ -477,11 +778,15 @@ impl Page {
eraser_pos: glam::Vec2, eraser_pos: glam::Vec2,
eraser_radius: f32, eraser_radius: f32,
max_stroke_radius: f32, max_stroke_radius: f32,
mode: EraserMode,
) -> Vec<Stroke> { ) -> Vec<Stroke> {
let mut hit_indices = Vec::new(); let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() { for (i, stroke) in self.strokes.iter().enumerate() {
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) { if !stroke.is_image()
&& mode.erases(stroke.tool)
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
{
hit_indices.push(i); hit_indices.push(i);
} }
} }
@@ -504,6 +809,66 @@ impl Page {
removed removed
} }
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
/// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs
/// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_partial_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
next_id: &mut u64,
) -> bool {
let mut removed = Vec::new();
let mut added = Vec::new();
let mut result = Vec::with_capacity(self.strokes.len());
for stroke in std::mem::take(&mut self.strokes) {
let reach = eraser_radius + stroke.width / 2.0;
if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
result.push(stroke);
continue;
}
if stroke.shape.is_some() {
// Formen werden ganz radiert, sobald der Radierer sie berührt
if stroke.distance_to_point(eraser_pos) <= reach {
removed.push(stroke);
} else {
result.push(stroke);
}
continue;
}
match split_samples_at_circle(&stroke.points, eraser_pos, reach, stroke.width * 0.5) {
None => result.push(stroke),
Some(pieces) => {
for samples in pieces {
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
stroke.color,
stroke.width,
stroke.tool,
true,
);
let mut piece = Stroke::new(*next_id, samples, vertices, stroke.color);
*next_id += 1;
piece.tool = stroke.tool;
piece.width = stroke.width;
piece.finalized = true;
result.push(piece.clone());
added.push(piece);
}
removed.push(stroke);
}
}
}
self.strokes = result;
if removed.is_empty() {
return false;
}
self.history.push_split(removed, added);
self.is_dirty = true;
true
}
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist. /// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
pub fn rebuild_cache_if_dirty(&mut self) { pub fn rebuild_cache_if_dirty(&mut self) {
if !self.is_dirty { if !self.is_dirty {
@@ -535,6 +900,8 @@ impl Page {
} }
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen. /// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> { pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
let sel_box = Aabb::new( let sel_box = Aabb::new(
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)), glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
@@ -542,7 +909,13 @@ impl Page {
); );
self.strokes self.strokes
.iter() .iter()
.filter(|s| s.aabb.intersects(&sel_box)) .filter(|s| {
if s.is_image() {
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
} else {
s.aabb.intersects(&sel_box)
}
})
.map(|s| s.id) .map(|s| s.id)
.collect() .collect()
} }
@@ -639,6 +1012,10 @@ impl Page {
} }
} }
/// Höhe des Bereichs am unteren Rand der letzten Seite, in dem Schreiben in der
/// durchgehenden Ansicht automatisch eine neue Seite anhängt.
pub const AUTO_APPEND_ZONE: f32 = PAGE_HEIGHT * 0.2;
/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation. /// Das Notizbuch mit diskreter Seitenverwaltung und Navigation.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Notebook { pub struct Notebook {
@@ -696,6 +1073,25 @@ impl Notebook {
&mut self.pages[self.current_page_index] &mut self.pages[self.current_page_index]
} }
/// Radiert auf der aktuellen Seite gemäß `mode` (ganze Striche, nur Textmarker oder
/// Teilstriche). Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
mode: EraserMode,
) -> bool {
let page = &mut self.pages[self.current_page_index];
if mode == EraserMode::Partial {
page.erase_partial_at(eraser_pos, eraser_radius, &mut self.next_stroke_id)
} else {
!page
.erase_at(eraser_pos, eraser_radius, max_stroke_radius, mode)
.is_empty()
}
}
/// Erzeugt eine eindeutige neue Stroke-ID. /// Erzeugt eine eindeutige neue Stroke-ID.
pub fn next_stroke_id(&mut self) -> u64 { pub fn next_stroke_id(&mut self) -> u64 {
let id = self.next_stroke_id; let id = self.next_stroke_id;
@@ -711,6 +1107,20 @@ impl Notebook {
self.pages.len() - 1 self.pages.len() - 1
} }
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
/// unteres Fünftel ([`AUTO_APPEND_ZONE`]), wird darunter eine leere Seite mit gleichem
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool {
if self.current_page_index + 1 != self.pages.len()
|| stroke_bottom < PAGE_HEIGHT - AUTO_APPEND_ZONE
{
return false;
}
let background = self.current_page().background;
self.add_page(background);
true
}
/// Wechselt zur nächsten Seite (PageDown / N). Hängt automatisch eine neue Seite an, falls am Ende. /// Wechselt zur nächsten Seite (PageDown / N). Hängt automatisch eine neue Seite an, falls am Ende.
pub fn next_page(&mut self) { pub fn next_page(&mut self) {
if self.current_page_index + 1 < self.pages.len() { if self.current_page_index + 1 < self.pages.len() {
@@ -742,6 +1152,20 @@ impl Notebook {
} }
} }
/// Setzt den Titel der Seite `page_id` (leer = kein Titel). `true`, wenn er sich geändert
/// hat.
pub fn set_page_title(&mut self, page_id: u64, title: &str) -> bool {
let title = normalize_page_title(title);
let Some(page) = self.pages.iter_mut().find(|p| p.id == page_id) else {
return false;
};
if page.title == title || !page.is_loaded || page.is_readonly() {
return false;
}
page.title = title;
true
}
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12". /// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String { pub fn osd_text(&self) -> String {
format!( format!(
@@ -1040,7 +1464,7 @@ mod tests {
assert_eq!(page.strokes.len(), 2); assert_eq!(page.strokes.len(), 2);
// Radieren bei (30, 12): Trifft nur s1 // Radieren bei (30, 12): Trifft nur s1
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0); let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All);
assert_eq!(removed.len(), 1); assert_eq!(removed.len(), 1);
assert_eq!(removed[0].id, 1); assert_eq!(removed[0].id, 1);
assert_eq!(page.strokes.len(), 1); assert_eq!(page.strokes.len(), 1);
@@ -1056,6 +1480,310 @@ mod tests {
assert_eq!(page.strokes[0].id, 2); assert_eq!(page.strokes[0].id, 2);
} }
#[test]
fn test_erase_highlighter_only_keeps_handwriting() {
let mut page = Page::new(1, PageBackground::Grid5mm);
let line = |id: u64, tool: StrokeTool| {
let mut stroke = Stroke::new(
id,
vec![
StylusSample {
position: glam::Vec2::new(10.0, 10.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(50.0, 10.0),
pressure: 0.5,
timestamp: 0.01,
},
],
vec![
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
);
stroke.tool = tool;
stroke
};
// Handschrift, Form und Textmarker liegen übereinander
page.add_stroke(line(1, StrokeTool::Pen));
page.add_stroke(line(2, StrokeTool::Highlighter));
page.add_stroke(line(3, StrokeTool::Shape));
let at = glam::Vec2::new(30.0, 10.0);
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert_eq!(
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
vec![1, 3]
);
// Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag
assert!(page
.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly)
.is_empty());
assert!(page.undo());
assert_eq!(page.strokes.len(), 3);
// Normaler Radierer entfernt alles
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All);
assert_eq!(removed.len(), 3);
assert!(page.strokes.is_empty());
}
#[test]
fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center);
let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
assert_eq!(tr.y, tl.y);
// Am Rand eingefügt: wird aufs Blatt geschoben
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0));
for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
}
}
#[test]
fn test_image_objects_hit_test_and_survive_erasing() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(300.0, 100.0),
glam::Vec2::new(300.0, 200.0),
glam::Vec2::new(100.0, 200.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
assert_eq!(page.strokes[0].image_corners(), Some(corners));
// Fläche des Bildes trifft, auch weit weg vom Rand
let inside = glam::Vec2::new(200.0, 150.0);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(1));
assert!(page
.find_stroke_at(glam::Vec2::new(400.0, 150.0), 5.0)
.is_none());
// Notiz auf dem Bild hat beim Antippen Vorrang
let mut ink = Stroke::new(
2,
vec![
StylusSample {
position: glam::Vec2::new(150.0, 150.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(250.0, 152.0),
pressure: 0.5,
timestamp: 0.1,
},
],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
ink.width = 5.0;
page.add_stroke(ink);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(2));
// Radierer entfernt nur die Notiz, nie das Bild
let removed = page.erase_at(inside, 30.0, 12.0, EraserMode::All);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert!(page
.erase_at(glam::Vec2::new(100.0, 100.0), 30.0, 12.0, EraserMode::All)
.is_empty());
// Auswahlrechteck: Bild nur bei vollständigem Einschluss
assert!(page
.find_strokes_in_box(glam::Vec2::new(150.0, 120.0), glam::Vec2::new(250.0, 180.0))
.is_empty());
assert_eq!(
page.find_strokes_in_box(glam::Vec2::new(90.0, 90.0), glam::Vec2::new(310.0, 210.0)),
vec![1]
);
// Umfärben ändert Bilder nicht, Drehen bewegt die Ecken mit
let mut image = page.strokes[0].clone();
image.set_color([1.0, 0.0, 0.0, 1.0]);
assert_eq!(image.color, [1.0, 1.0, 1.0, 1.0]);
image.rotate(glam::Vec2::new(200.0, 150.0), std::f32::consts::FRAC_PI_2);
let rotated = image.image_corners().unwrap();
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
}
fn line_samples(points: &[(f32, f32)]) -> Vec<StylusSample> {
points
.iter()
.enumerate()
.map(|(i, &(x, y))| StylusSample {
position: glam::Vec2::new(x, y),
pressure: 0.5,
timestamp: i as f64,
})
.collect()
}
#[test]
fn test_split_samples_at_circle() {
let c = glam::Vec2::new(50.0, 0.0);
// Lange Strecke mit nur zwei Punkten, Kreis in der Mitte: zwei Stücke, am Rand gekürzt
let pieces =
split_samples_at_circle(&line_samples(&[(0.0, 0.0), (100.0, 0.0)]), c, 10.0, 1.0)
.unwrap();
assert_eq!(pieces.len(), 2);
assert!((pieces[0].last().unwrap().position.x - 40.0).abs() < 1e-3);
assert!((pieces[1][0].position.x - 60.0).abs() < 1e-3);
// Druck und Zeit werden am Schnittpunkt interpoliert
assert!((pieces[0].last().unwrap().timestamp - 0.4).abs() < 1e-3);
// Viele Punkte, Kreis am Ende: ein Stück bleibt
let dense: Vec<(f32, f32)> = (0..=10).map(|i| (i as f32 * 10.0, 0.0)).collect();
let pieces = split_samples_at_circle(
&line_samples(&dense),
glam::Vec2::new(100.0, 0.0),
15.0,
1.0,
)
.unwrap();
assert_eq!(pieces.len(), 1);
assert!((pieces[0].last().unwrap().position.x - 85.0).abs() < 1e-3);
// Ganz im Kreis: nichts bleibt; Kreis weit weg: unberührt
assert_eq!(
split_samples_at_circle(&line_samples(&[(48.0, 0.0), (52.0, 0.0)]), c, 10.0, 1.0),
Some(Vec::new())
);
assert_eq!(
split_samples_at_circle(
&line_samples(&dense),
glam::Vec2::new(50.0, 40.0),
10.0,
1.0
),
None
);
// Winziger Rest wird verworfen
let pieces = split_samples_at_circle(
&line_samples(&[(0.0, 0.0), (100.0, 0.0)]),
glam::Vec2::new(5.0, 0.0),
4.0,
2.0,
)
.unwrap();
assert_eq!(pieces.len(), 1);
assert!((pieces[0][0].position.x - 9.0).abs() < 1e-3);
}
#[test]
fn test_partial_erase_splits_and_undoes_in_one_step() {
let mut notebook = Notebook::new();
let mut stroke = Stroke::new(
1,
line_samples(&[(100.0, 100.0), (500.0, 100.0)]),
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
stroke.width = 5.0;
notebook.next_stroke_id = 10;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
page.add_stroke(Stroke::new_image(
2,
"a.png".into(),
[
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(900.0, 0.0),
glam::Vec2::new(900.0, 900.0),
glam::Vec2::new(0.0, 900.0),
],
));
let mut shape = crate::geometry::shape_to_stroke(
crate::geometry::RecognizedShape::Line {
start: glam::Vec2::new(100.0, 300.0),
end: glam::Vec2::new(500.0, 300.0),
},
3,
[0.0, 0.0, 0.0, 1.0],
5.0,
);
shape.recompute_aabb();
page.add_stroke(shape);
let undo_depth = page.history.undo_stack.len();
// Zwei Schnitte desselben Zugs durch die Linie
assert!(notebook.erase_at(
glam::Vec2::new(200.0, 100.0),
20.0,
12.0,
EraserMode::Partial
));
assert!(notebook.erase_at(
glam::Vec2::new(400.0, 100.0),
20.0,
12.0,
EraserMode::Partial
));
// Form wird als Ganzes radiert
assert!(notebook.erase_at(
glam::Vec2::new(300.0, 300.0),
20.0,
12.0,
EraserMode::Partial
));
let page = notebook.current_page();
let ink: Vec<&Stroke> = page.strokes.iter().filter(|s| !s.is_image()).collect();
assert_eq!(ink.len(), 3, "Linie in drei Stücke zerlegt, Form entfernt");
assert!(ink.iter().all(|s| s.id >= 10 && !s.vertices.is_empty()));
assert!(page.strokes.iter().any(|s| s.id == 2), "Bild bleibt");
assert!(notebook.next_stroke_id > 10);
// Ein einziger Undo-Schritt stellt alles wieder her
let page = notebook.current_page_mut();
assert_eq!(page.history.undo_stack.len(), undo_depth + 1);
assert!(page.undo());
let mut ids: Vec<u64> = page.strokes.iter().map(|s| s.id).collect();
ids.sort();
assert_eq!(ids, vec![1, 2, 3]);
assert!(page.redo());
assert_eq!(page.strokes.iter().filter(|s| !s.is_image()).count(), 3);
// Danebenradieren ändert nichts
assert!(!notebook.erase_at(
glam::Vec2::new(800.0, 800.0),
20.0,
12.0,
EraserMode::Partial
));
}
#[test]
fn test_append_page_when_writing_at_bottom_of_last_page() {
let mut nb = Notebook::new();
nb.current_page_mut().background = PageBackground::Lined;
// Oben auf der Seite: nichts
assert!(!nb.append_page_if_writing_at_end(PAGE_HEIGHT * 0.5));
assert_eq!(nb.pages.len(), 1);
// Unten: neue Seite mit gleichem Hintergrund, die aktuelle Seite bleibt
assert!(nb.append_page_if_writing_at_end(PAGE_HEIGHT - 10.0));
assert_eq!(nb.pages.len(), 2);
assert_eq!(nb.current_page_index, 0);
assert_eq!(nb.pages[1].background, PageBackground::Lined);
// Nicht auf der letzten Seite: nichts
assert!(!nb.append_page_if_writing_at_end(PAGE_HEIGHT - 10.0));
assert_eq!(nb.pages.len(), 2);
}
#[test] #[test]
fn test_page_isolation() { fn test_page_isolation() {
let mut nb = Notebook::new(); let mut nb = Notebook::new();
+705 -75
View File
@@ -2,7 +2,7 @@
//! //!
//! Features: //! Features:
//! - Hochformat DIN A4 (210 mm x 297 mm) //! - Hochformat DIN A4 (210 mm x 297 mm)
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink") //! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links) //! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) //! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
//! - Zwei Export-Strategien: //! - Zwei Export-Strategien:
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder}; use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::assets::AssetStore;
use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{ use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
@@ -78,6 +79,20 @@ pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
} }
} }
/// Kollisionsfreier Pfad `~/Documents/<name>.<ext>` (Verzeichnis wird angelegt).
pub fn resolve_export_path(name: &str, ext: &str) -> PathBuf {
let dir = default_documents_dir();
let _ = std::fs::create_dir_all(&dir);
let base = sanitize_filename_component(name);
let mut candidate = dir.join(format!("{base}.{ext}"));
let mut counter = 1;
while candidate.exists() {
candidate = dir.join(format!("{base}_{counter}.{ext}"));
counter += 1;
}
candidate
}
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR /// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig. /// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
pub fn resolve_pdf_export_path(title: &str) -> PathBuf { pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
@@ -109,15 +124,18 @@ pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
} }
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. /// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
pub fn export_notebook_to_pdf<P: AsRef<Path>>( pub fn export_notebook_to_pdf<P: AsRef<Path>>(
notebook: &Notebook, notebook: &Notebook,
path: P, path: P,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?;
let file = File::create(path)?; let file = File::create(path)?;
let mut writer = BufWriter::new(file); let mut writer = BufWriter::new(file);
let doc = generate_pdf_document(notebook, strategy)?; std::io::Write::write_all(&mut writer, &bytes)?;
doc.save(&mut writer)?; std::io::Write::flush(&mut writer)?;
Ok(()) Ok(())
} }
@@ -125,16 +143,135 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
pub fn export_notebook_to_bytes( pub fn export_notebook_to_bytes(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy)?; let doc = generate_pdf_document(notebook, strategy, assets)?;
let bytes = doc.save_to_bytes()?; let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
Ok(bytes)
// Importierte PDF-Seiten als Vektorgrafik unterlegen
let underlays = pdf_underlays(notebook);
if underlays.is_empty() {
return Ok(bytes);
}
let sources: std::collections::HashMap<String, Vec<u8>> = underlays
.iter()
.filter_map(|u| Some((u.source.clone(), assets?.read_bytes(&u.source).ok()?)))
.collect();
let (merged, failed) = crate::pdf_merge::apply_underlays(&bytes, &underlays, &sources)?;
if failed.is_empty() {
return Ok(merged);
}
// Nicht übernehmbare Seiten (z. B. verschlüsselte PDFs) als Bild einbetten
let mut raster = std::collections::HashSet::new();
for (underlay, err) in &failed {
eprintln!(
"[PDF-Export] Seite {} des PDFs als Bild statt als Vektor: {err}",
underlay.source_page + 1
);
raster.insert(underlay.target_page);
}
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let remaining: Vec<_> = underlays
.into_iter()
.filter(|u| !raster.contains(&u.target_page))
.collect();
if remaining.is_empty() {
return Ok(bytes);
}
let (merged, _) = crate::pdf_merge::apply_underlays(&bytes, &remaining, &sources)?;
Ok(merged)
}
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
let needs_fix = notebook
.pages
.iter()
.filter_map(|p| p.title.as_deref())
.any(|t| !t.is_ascii());
if !needs_fix {
return Ok(bytes);
}
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
for object in doc.objects.values_mut() {
let Ok(dict) = object.as_dict_mut() else {
continue;
};
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
if let Some(utf16) = utf16be_text(raw) {
*raw = utf16;
*format = printpdf::lopdf::StringFormat::Hexadecimal;
}
}
}
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
/// ungültigem UTF-8.
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
let text = std::str::from_utf8(raw).ok()?;
if text.is_ascii() {
return None;
}
let mut out = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
out.extend_from_slice(&unit.to_be_bytes());
}
Some(out)
}
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
notebook
.pages
.iter()
.enumerate()
.filter_map(|(i, page)| {
let pdf = page.pdf_background.as_ref()?;
let corners = pdf.corners();
let top_right = page_to_pdf_pt(corners[1]);
let bottom_left = page_to_pdf_pt(corners[3]);
let size = top_right - bottom_left;
Some(crate::pdf_merge::Underlay {
target_page: i,
source: pdf.asset.clone(),
source_page: pdf.page as usize,
rect: [bottom_left.x, bottom_left.y, size.x, size.y],
})
})
.collect()
} }
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs. /// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
pub fn generate_pdf_document( pub fn generate_pdf_document(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<PdfDocumentReference, Box<dyn Error>> {
generate_pdf_document_with(
notebook,
strategy,
assets,
&std::collections::HashSet::new(),
)
}
/// Wie [`generate_pdf_document`]; die PDF-Seiten der Seiten in `raster_pdf_pages` werden
/// gerendert und als Bild eingebettet.
fn generate_pdf_document_with(
notebook: &Notebook,
strategy: ExportStrategy,
assets: Option<&AssetStore>,
raster_pdf_pages: &std::collections::HashSet<usize>,
) -> Result<PdfDocumentReference, Box<dyn Error>> { ) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() { if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into()); return Err("Notizbuch enthält keine Seiten".into());
@@ -155,20 +292,40 @@ pub fn generate_pdf_document(
let (doc, page1_idx, layer1_bg) = let (doc, page1_idx, layer1_bg) =
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
if let Some(title) = &notebook.pages[0].title {
doc.add_bookmark(title.clone(), page1_idx);
}
// Seite 1 befüllen // Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx); let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg); let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
render_background_layer(&layer1_bg_ref, notebook.pages[0].background); render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
if raster_pdf_pages.contains(&0) {
render_pdf_raster(&page1_ref.add_layer("PDF"), &notebook.pages[0], assets);
}
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
render_image_layer(&page1_ref.add_layer("Images"), &notebook.pages[0], assets);
}
let layer1_ink_ref = page1_ref.add_layer("Ink"); let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy); render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
// 2. Alle nachfolgenden Seiten hinzufügen // 2. Alle nachfolgenden Seiten hinzufügen
for page in &notebook.pages[1..] { for (i, page) in notebook.pages.iter().enumerate().skip(1) {
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
if let Some(title) = &page.title {
doc.add_bookmark(title.clone(), page_idx);
}
let page_ref = doc.get_page(page_idx); let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg); let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background); render_background_layer(&layer_bg_ref, page.background);
if raster_pdf_pages.contains(&i) {
render_pdf_raster(&page_ref.add_layer("PDF"), page, assets);
}
if page.strokes.iter().any(Stroke::is_image) {
render_image_layer(&page_ref.add_layer("Images"), page, assets);
}
let layer_ink_ref = page_ref.add_layer("Ink"); let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy); render_ink_layer(&layer_ink_ref, page, strategy);
@@ -268,6 +425,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). /// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
for stroke in &page.strokes { for stroke in &page.strokes {
if stroke.is_image() {
continue;
}
if let Some(ref shape) = stroke.shape { if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color); render_recognized_shape(layer, shape, stroke.color);
} else { } else {
@@ -283,6 +443,210 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
} }
} }
/// Auflösung, mit der Bilder höchstens eingebettet werden (Druckqualität).
pub const PDF_IMAGE_DPI: f32 = 300.0;
/// Seitenkoordinate in PDF-Punkten (ohne Begrenzung auf das Blatt, Ursprung unten links).
fn page_to_pdf_pt(p: glam::Vec2) -> glam::Vec2 {
const PT_PER_MM: f32 = 72.0 / 25.4;
glam::Vec2::new(
p.x / PAGE_WIDTH * A4_WIDTH_MM * PT_PER_MM,
(1.0 - p.y / PAGE_HEIGHT) * A4_HEIGHT_MM * PT_PER_MM,
)
}
/// PDF-Transformationsmatrix, die das Einheitsquadrat eines Bildes (Ursprung unten links)
/// auf die Bildecken oben links, oben rechts, unten rechts, unten links abbildet.
pub fn image_matrix(corners: &[glam::Vec2; 4]) -> [f32; 6] {
let [tl, _, br, bl] = corners.map(page_to_pdf_pt);
let x_axis = br - bl;
let y_axis = tl - bl;
[x_axis.x, x_axis.y, y_axis.x, y_axis.y, bl.x, bl.y]
}
/// Anzahl der Bildkomponenten eines JPEG laut Frame-Header (1 = Graustufen, 3 = Farbe,
/// 4 = CMYK), `None` bei unlesbarem Header.
fn jpeg_components(bytes: &[u8]) -> Option<u8> {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
return None;
}
let marker = bytes[i + 1];
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
// SOF0–SOF15 außer DHT (C4), JPG (C8) und DAC (CC)
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
return bytes.get(i + 9).copied();
}
i += 2 + len;
}
None
}
/// Bilddaten für das PDF: JPEG-Dateien werden unverändert eingebettet, alles andere als
/// RGB auf weißem Grund (Transparenz wird mit dem Papier verrechnet), höchstens mit
/// `PDF_IMAGE_DPI` bezogen auf die Größe auf der Seite.
fn pdf_image_xobject(
assets: &AssetStore,
asset: &str,
corners: &[glam::Vec2; 4],
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
let bytes = assets.read_bytes(asset)?;
let decoded = image::load_from_memory(&bytes)?;
if asset.ends_with(".jpg") {
let color_space = match jpeg_components(&bytes) {
Some(1) => Some(ColorSpace::Greyscale),
Some(3) => Some(ColorSpace::Rgb),
_ => None,
};
if let Some(color_space) = color_space {
return Ok(ImageXObject {
width: Px(decoded.width() as usize),
height: Px(decoded.height() as usize),
color_space,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: bytes,
image_filter: Some(ImageFilter::DCT),
smask: None,
clipping_bbox: None,
});
}
}
// Größe auf der Seite in Zoll → benötigte Pixel
let width_in = (corners[1] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let height_in = (corners[3] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let max_w = ((width_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let max_h = ((height_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let decoded = if decoded.width() > max_w || decoded.height() > max_h {
decoded.resize(max_w, max_h, image::imageops::FilterType::Triangle)
} else {
decoded
};
let rgba = decoded.into_rgba8();
let mut rgb = Vec::with_capacity((rgba.width() * rgba.height() * 3) as usize);
for px in rgba.pixels() {
let alpha = px[3] as u32;
for c in &px.0[..3] {
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
}
}
Ok(rgb_xobject(rgba.width(), rgba.height(), rgb))
}
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
fn render_image_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
for stroke in &page.strokes {
let (Some(asset), Some(corners)) = (stroke.image.as_deref(), stroke.image_corners()) else {
continue;
};
let xobject = match assets.map(|a| pdf_image_xobject(a, asset, &corners)) {
Some(Ok(xobject)) => xobject,
other => {
if let Some(Err(err)) = other {
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
}
placeholder_xobject()
}
};
draw_image_xobject(layer, xobject, &corners);
}
}
/// Graue 1×1-Fläche für fehlende Bilder.
fn placeholder_xobject() -> printpdf::ImageXObject {
rgb_xobject(1, 1, vec![200, 200, 200])
}
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> printpdf::ImageXObject {
printpdf::ImageXObject {
width: printpdf::Px(width as usize),
height: printpdf::Px(height as usize),
color_space: printpdf::ColorSpace::Rgb,
bits_per_component: printpdf::ColorBits::Bit8,
interpolate: true,
image_data: rgb,
image_filter: None,
smask: None,
clipping_bbox: None,
}
}
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
/// unten links ausfüllt.
fn draw_image_xobject(
layer: &printpdf::PdfLayerReference,
xobject: printpdf::ImageXObject,
corners: &[glam::Vec2; 4],
) {
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
layer.save_graphics_state();
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners)));
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
printpdf::Image::from(xobject).add_to_layer(
layer.clone(),
printpdf::ImageTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / width),
scale_y: Some(1.0 / height),
..Default::default()
},
);
layer.restore_graphics_state();
}
/// Auflösung, mit der PDF-Seiten gerendert werden, die nicht als Vektor übernommen
/// werden können.
const PDF_RASTER_DPI: f32 = 200.0;
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
fn render_pdf_raster(
layer: &printpdf::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
let Some(pdf) = &page.pdf_background else {
return;
};
let longest_pt = pdf.width_pt.max(pdf.height_pt);
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
let rendered = assets
.ok_or_else(|| "kein Asset-Ordner".to_string())
.and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string()))
.and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels));
let xobject = match rendered {
Ok(image) => {
let rgb = image
.pixels
.as_chunks::<4>()
.0
.iter()
.flat_map(|px| [px[0], px[1], px[2]])
.collect();
rgb_xobject(image.width, image.height, rgb)
}
Err(err) => {
eprintln!(
"[PDF-Export] PDF-Seite {} nicht darstellbar: {err}",
pdf.page + 1
);
placeholder_xobject()
}
};
draw_image_xobject(layer, xobject, &pdf.corners());
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF. /// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape( fn render_recognized_shape(
@@ -329,6 +693,59 @@ fn render_recognized_shape(
layer.add_polygon(poly); layer.add_polygon(poly);
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
let ring = (0..72)
.map(|i| {
let t = i as f32 / 72.0 * std::f32::consts::TAU;
let p =
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
})
.collect();
layer.add_polygon(Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
});
}
RecognizedShape::Arrow { start, end, double } => {
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let start_head =
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let line_start = if *double { start_head.0 } else { *start };
let to_pdf = |p: glam::Vec2| {
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
};
layer.add_line(Line {
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
is_closed: false,
});
layer.set_fill_color(stroke_color);
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in heads {
layer.add_polygon(Polygon {
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
RecognizedShape::Rectangle { corners } => { RecognizedShape::Rectangle { corners } => {
let mut ring = Vec::with_capacity(4); let mut ring = Vec::with_capacity(4);
for c in corners { for c in corners {
@@ -412,76 +829,58 @@ fn render_recognized_shape(
y_axis_end, y_axis_end,
with_arrows, with_arrows,
} => { } => {
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = crate::geometry::arrow_head(
*y_axis_end,
y_dir,
PDF_SHAPE_HALF_WIDTH,
(*y_axis_end - *origin).length(),
);
let x_arrow = crate::geometry::arrow_head(
*x_axis_end,
x_dir,
PDF_SHAPE_HALF_WIDTH,
(*x_axis_end - *origin).length(),
);
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); for end in [y_line_end, x_line_end] {
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
// Achse 1 (Y) points: vec![
let line_y = Line { (Point::new(Mm(ox), Mm(oy)), false),
points: vec![ (Point::new(Mm(ex), Mm(ey)), false),
(Point::new(Mm(ox), Mm(oy)), false), ],
(Point::new(Mm(yx), Mm(yy)), false), is_closed: false,
], });
is_closed: false, }
};
layer.add_line(line_y);
// Achse 2 (X)
let line_x = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(xx), Mm(xy)), false),
],
is_closed: false,
};
layer.add_line(line_x);
// Pfeilspitzen als gefüllte Dreiecke // Pfeilspitzen als gefüllte Dreiecke
if *with_arrows { if *with_arrows {
layer.set_fill_color(stroke_color); layer.set_fill_color(stroke_color);
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let y_dir = (*y_axis_end - *origin).normalize_or_zero(); let ring = [tip, left, right]
let arrow_len_page = 25.0; .iter()
let arrow_w_page = 16.0; .map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
let base_y = *y_axis_end - y_dir * arrow_len_page; (Point::new(Mm(px), Mm(py)), false)
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); })
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); .collect();
layer.add_polygon(Polygon {
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); rings: vec![ring],
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y); mode: PaintMode::Fill,
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y); winding_order: WindingOrder::NonZero,
});
let arrow_poly_y = Polygon { }
rings: vec![vec![
(Point::new(Mm(tx), Mm(ty)), false),
(Point::new(Mm(lx), Mm(ly)), false),
(Point::new(Mm(rx), Mm(ry)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_y);
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let base_x = *x_axis_end - x_dir * arrow_len_page;
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
let arrow_poly_x = Polygon {
rings: vec![vec![
(Point::new(Mm(tx_x), Mm(ty_x)), false),
(Point::new(Mm(lx_x), Mm(ly_x)), false),
(Point::new(Mm(rx_x), Mm(ry_x)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_x);
} }
} }
} }
@@ -759,7 +1158,7 @@ mod tests {
let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = let export_res =
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon); export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon, None);
assert!( assert!(
export_res.is_ok(), export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein" "PDF-Export zu Datei muss erfolgreich sein"
@@ -785,7 +1184,7 @@ mod tests {
); );
// 3. Export als Bytes testen (Strategie B: BezierCurve) // 3. Export als Bytes testen (Strategie B: BezierCurve)
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve); let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve, None);
assert!(bytes_res.is_ok()); assert!(bytes_res.is_ok());
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
@@ -838,6 +1237,27 @@ mod tests {
); );
page.add_stroke(coords_stroke); page.add_stroke(coords_stroke);
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(1400.0, 600.0),
radius_x: 300.0,
radius_y: 150.0,
rotation: 0.4,
},
);
page.add_stroke(ellipse_stroke);
for (id, double) in [(41, false), (42, true)] {
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
double,
},
);
page.add_stroke(arrow_stroke);
}
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape( let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
crate::geometry::RecognizedShape::Triangle { crate::geometry::RecognizedShape::Triangle {
corners: [ corners: [
@@ -887,13 +1307,223 @@ mod tests {
); );
page.add_stroke(octahedron_stroke); page.add_stroke(octahedron_stroke);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon); let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None);
assert!(bytes_res.is_ok(), "Shape export must succeed"); assert!(bytes_res.is_ok(), "Shape export must succeed");
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1.")); assert!(bytes.starts_with(b"%PDF-1."));
} }
#[test]
fn test_image_matrix_maps_unit_square_to_corners() {
// Bild über die volle Seitenbreite, oberes Viertel der Seite
let corners = [
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(PAGE_WIDTH, 0.0),
glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT / 4.0),
glam::Vec2::new(0.0, PAGE_HEIGHT / 4.0),
];
let [a, b, c, d, e, f] = image_matrix(&corners);
let page_w_pt = A4_WIDTH_MM * 72.0 / 25.4;
let page_h_pt = A4_HEIGHT_MM * 72.0 / 25.4;
let map = |u: f32, v: f32| (a * u + c * v + e, b * u + d * v + f);
let close = |(x, y): (f32, f32), (ex, ey): (f32, f32)| {
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01
};
// Unten links des Bildes, oben rechts des Bildes (PDF-Ursprung unten links)
assert!(close(map(0.0, 0.0), (0.0, page_h_pt * 0.75)));
assert!(close(map(1.0, 1.0), (page_w_pt, page_h_pt)));
// Gedrehtes Bild: Achsen folgen den Ecken
let rotated = [
glam::Vec2::new(500.0, 500.0),
glam::Vec2::new(500.0, 900.0),
glam::Vec2::new(300.0, 900.0),
glam::Vec2::new(300.0, 500.0),
];
let m = image_matrix(&rotated);
assert!(
m[0].abs() < 1e-3 && m[1] < 0.0,
"x-Achse zeigt nach unten: {m:?}"
);
}
#[test]
fn test_pdf_export_embeds_images() {
let dir = tempfile::tempdir().unwrap();
let assets = AssetStore::new(dir.path());
let jpg = crate::assets::tests::sample_image_bytes(64, 48, image::ImageFormat::Jpeg);
let png = crate::assets::tests::sample_image_bytes(30, 20, image::ImageFormat::Png);
let jpg_asset = assets.import_bytes(&jpg).unwrap().asset;
let png_asset = assets.import_bytes(&png).unwrap().asset;
let mut notebook = Notebook::new();
let page = notebook.current_page_mut();
for (id, asset) in [
(1, jpg_asset),
(2, png_asset),
(3, format!("{}.png", "0".repeat(64))),
] {
let y = id as f32 * 400.0;
page.strokes.push(Stroke::new_image(
id,
asset,
[
glam::Vec2::new(100.0, y),
glam::Vec2::new(700.0, y),
glam::Vec2::new(700.0, y + 300.0),
glam::Vec2::new(100.0, y + 300.0),
],
));
}
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets))
.expect("Export mit Bildern");
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(
contains(b"/DCTDecode"),
"JPEG wird nicht unverändert eingebettet"
);
// JPEG-Daten liegen byte-identisch im PDF
assert!(contains(&jpg[..64]));
assert!(
contains(b"/Images") || contains(b"(Images)"),
"Bild-Ebene fehlt"
);
let image_objects = bytes
.windows(b"/Subtype/Image".len())
.filter(|w| *w == b"/Subtype/Image")
.count()
+ bytes
.windows(b"/Subtype /Image".len())
.filter(|w| *w == b"/Subtype /Image")
.count();
// JPEG, PNG und Ersatzfläche für die fehlende Datei
assert_eq!(image_objects, 3);
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_pdf_export_underlays_imported_pages() {
let temp_dir = tempfile::tempdir().unwrap();
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 90)]);
let (mut notebook, _, dir) =
crate::storage::create_notebook_from_pdf(temp_dir.path(), "Folien", source).unwrap();
crate::storage::load_all_pages(&dir, &mut notebook).unwrap();
let assets = AssetStore::new(&dir);
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets))
.unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
assert_eq!(doc.get_pages().len(), 2);
for page_id in doc.get_pages().into_values() {
let content = doc.get_page_content(page_id).unwrap();
assert!(
String::from_utf8_lossy(&content).starts_with("q "),
"Originalseite fehlt als Unterlage"
);
}
// Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links
let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap();
let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap();
let px = |x: u32, y: u32| {
let i = ((y * image.width + x) * 4) as usize;
image.pixels[i..i + 3].to_vec()
};
assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links");
assert_eq!(
px(200, 500),
vec![255, 255, 255],
"unter der Folie ist Platz"
);
// Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt
// dort oben rechts, in der Quelle wie im Export
let source_pdf = hayro::hayro_syntax::Pdf::new(
assets
.read_bytes(&notebook.pages[1].pdf_background.as_ref().unwrap().asset)
.unwrap(),
)
.unwrap();
let shown = crate::pdf_doc::render_page(&source_pdf, 1, 600).unwrap();
let red_at = |img: &crate::assets::RgbaImage, x: u32, y: u32| {
let i = ((y * img.width + x) * 4) as usize;
img.pixels[i..i + 3] == [255, 0, 0]
};
assert!(red_at(&shown, shown.width - 5, 5));
let exported = crate::pdf_doc::render_page(&pdf, 1, 600).unwrap();
assert!(red_at(&exported, exported.width - 5, 5));
assert!(!red_at(&exported, 5, 5));
// Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_page_titles_become_pdf_bookmarks() {
let mut notebook = Notebook::new();
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
notebook.set_page_title(ids[0], "Einleitung");
notebook.set_page_title(ids[2], "Übung 2 – Integrale");
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
let catalog = doc.catalog().unwrap();
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
// Titel als PDF-Textstrings: ASCII unverändert, Umlaute als UTF-16BE mit BOM
let titles: Vec<String> = doc
.objects
.values()
.filter_map(|o| o.as_dict().ok())
.filter(|d| d.has(b"Parent"))
.filter_map(|d| match d.get(b"Title") {
Ok(printpdf::lopdf::Object::String(raw, _)) => {
Some(if raw.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> = raw[2..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&units).unwrap()
} else {
String::from_utf8(raw.clone()).unwrap()
})
}
_ => None,
})
.collect();
assert!(titles.contains(&"Einleitung".to_string()), "{titles:?}");
assert!(
titles.contains(&"Übung 2 – Integrale".to_string()),
"{titles:?}"
);
}
#[test]
fn test_jpeg_component_count() {
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
assert_eq!(jpeg_components(&color), Some(3));
let gray = {
let img = image::GrayImage::from_pixel(8, 8, image::Luma([90]));
let mut out = Vec::new();
image::DynamicImage::ImageLuma8(img)
.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.unwrap();
out
};
assert_eq!(jpeg_components(&gray), Some(1));
assert_eq!(jpeg_components(b"\xFF\xD8kaputt"), None);
}
#[test] #[test]
fn test_sanitize_filename_component() { fn test_sanitize_filename_component() {
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1"); assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
+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);
}
}
+632 -67
View File
@@ -15,6 +15,8 @@ use crate::document::{Aabb, Stroke, StylusSample};
pub enum ShapeKind { pub enum ShapeKind {
Line, Line,
Circle, Circle,
Ellipse,
Arrow,
Rectangle, Rectangle,
CoordinateSystem, CoordinateSystem,
Triangle, Triangle,
@@ -34,6 +36,20 @@ pub enum RecognizedShape {
center: Vec2, center: Vec2,
radius: f32, radius: f32,
}, },
/// Ellipse mit den Halbachsen `radius_x` (entlang `rotation`) und `radius_y`.
Ellipse {
center: Vec2,
radius_x: f32,
radius_y: f32,
/// Drehwinkel der ersten Halbachse zur x-Achse (Bogenmaß)
rotation: f32,
},
/// Pfeil vom Schaftende `start` zur Spitze `end`; `double` = Spitzen an beiden Enden.
Arrow {
start: Vec2,
end: Vec2,
double: bool,
},
Rectangle { Rectangle {
/// 4 Eckpunkte in umlaufender Reihenfolge (strikt rechtwinklig). /// 4 Eckpunkte in umlaufender Reihenfolge (strikt rechtwinklig).
corners: [Vec2; 4], corners: [Vec2; 4],
@@ -70,6 +86,8 @@ impl RecognizedShape {
match self { match self {
Self::Line { .. } => ShapeKind::Line, Self::Line { .. } => ShapeKind::Line,
Self::Circle { .. } => ShapeKind::Circle, Self::Circle { .. } => ShapeKind::Circle,
Self::Ellipse { .. } => ShapeKind::Ellipse,
Self::Arrow { .. } => ShapeKind::Arrow,
Self::Rectangle { .. } => ShapeKind::Rectangle, Self::Rectangle { .. } => ShapeKind::Rectangle,
Self::CoordinateSystem { .. } => ShapeKind::CoordinateSystem, Self::CoordinateSystem { .. } => ShapeKind::CoordinateSystem,
Self::Triangle { .. } => ShapeKind::Triangle, Self::Triangle { .. } => ShapeKind::Triangle,
@@ -83,6 +101,9 @@ impl RecognizedShape {
match self { match self {
Self::Line { .. } => "Gerade", Self::Line { .. } => "Gerade",
Self::Circle { .. } => "Kreis", Self::Circle { .. } => "Kreis",
Self::Ellipse { .. } => "Ellipse",
Self::Arrow { double: false, .. } => "Pfeil",
Self::Arrow { double: true, .. } => "Doppelpfeil",
Self::Rectangle { .. } => "Rechteck", Self::Rectangle { .. } => "Rechteck",
Self::CoordinateSystem { .. } => "Koordinatensystem", Self::CoordinateSystem { .. } => "Koordinatensystem",
Self::Triangle { .. } => "Dreieck", Self::Triangle { .. } => "Dreieck",
@@ -104,9 +125,13 @@ impl RecognizedShape {
*start += delta; *start += delta;
*end += delta; *end += delta;
} }
Self::Circle { center, .. } => { Self::Circle { center, .. } | Self::Ellipse { center, .. } => {
*center += delta; *center += delta;
} }
Self::Arrow { start, end, .. } => {
*start += delta;
*end += delta;
}
Self::Rectangle { corners } | Self::Rhombus { corners } => { Self::Rectangle { corners } | Self::Rhombus { corners } => {
for c in corners { for c in corners {
*c += delta; *c += delta;
@@ -154,6 +179,16 @@ impl RecognizedShape {
Self::Circle { center, .. } => { Self::Circle { center, .. } => {
*center = rot(*center); *center = rot(*center);
} }
Self::Ellipse {
center, rotation, ..
} => {
*center = rot(*center);
*rotation += angle_rad;
}
Self::Arrow { start, end, .. } => {
*start = rot(*start);
*end = rot(*end);
}
Self::Rectangle { corners } | Self::Rhombus { corners } => { Self::Rectangle { corners } | Self::Rhombus { corners } => {
for c in corners { for c in corners {
*c = rot(*c); *c = rot(*c);
@@ -198,6 +233,26 @@ impl RecognizedShape {
*center = sc(*center); *center = sc(*center);
*radius *= (factors.x.abs() + factors.y.abs()) * 0.5; *radius *= (factors.x.abs() + factors.y.abs()) * 0.5;
} }
Self::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
// Halbachsen als Vektoren skalieren (bei schräger Ellipse und ungleicher
// Skalierung eine gute Näherung)
let (sin, cos) = rotation.sin_cos();
let axis_x = Vec2::new(cos, sin) * *radius_x * factors;
let axis_y = Vec2::new(-sin, cos) * *radius_y * factors;
*center = sc(*center);
*radius_x = axis_x.length();
*radius_y = axis_y.length();
*rotation = axis_x.y.atan2(axis_x.x);
}
Self::Arrow { start, end, .. } => {
*start = sc(*start);
*end = sc(*end);
}
Self::Rectangle { corners } | Self::Rhombus { corners } => { Self::Rectangle { corners } | Self::Rhombus { corners } => {
for c in corners { for c in corners {
*c = sc(*c); *c = sc(*c);
@@ -912,6 +967,221 @@ pub fn detect_polygon(points: &[Vec2]) -> Option<RecognizedShape> {
}) })
} }
/// Tastet einen Linienzug gleichmäßig entlang der Bogenlänge neu ab (`samples` Abschnitte).
fn resample_path(points: &[Vec2], samples: usize) -> Vec<Vec2> {
let total_len = path_length(points);
if points.is_empty() || total_len < 1e-3 || samples == 0 {
return points.to_vec();
}
let step = total_len / samples as f32;
let mut resampled = vec![points[0]];
let mut carried = 0.0;
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
let seg = (b - a).length();
let mut pos = step - carried;
while pos <= seg {
resampled.push(a + (b - a) * (pos / seg));
pos += step;
}
carried = seg - (pos - step);
}
resampled
}
/// Ellipsen, deren kürzere Halbachse mindestens diesen Anteil der längeren hat, gelten als Kreis.
const ELLIPSE_MAX_ROUNDNESS: f32 = 0.85;
/// Ellipsen, die höchstens so viele Grad schief liegen, werden achsenparallel ausgerichtet.
const ELLIPSE_AXIS_SNAP_DEG: f32 = 8.0;
/// Ellipsenerkennung: geschlossener Pfad, Achsen aus der Hauptkomponentenanalyse der
/// gleichmäßig abgetasteten Punkte, Halbachsen aus der Ausdehnung entlang dieser Achsen
/// (2.–98. Perzentil, robust gegen Überstriche). Fast runde Formen bleiben Kreise.
pub fn detect_ellipse(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 8 {
return None;
}
let total_len = path_length(points);
if total_len < 40.0 {
return None;
}
let chord_len = (*points.last().unwrap() - points[0]).length();
if chord_len > 0.35 * total_len {
return None;
}
let pts = resample_path(points, 120);
let n = pts.len() as f32;
let mean = pts.iter().copied().sum::<Vec2>() / n;
let (mut sxx, mut syy, mut sxy) = (0.0_f32, 0.0_f32, 0.0_f32);
for p in &pts {
let d = *p - mean;
sxx += d.x * d.x;
syy += d.y * d.y;
sxy += d.x * d.y;
}
let mut rotation = 0.5 * (2.0 * sxy).atan2(sxx - syy);
// Achsenparallel ausrichten, wenn nur leicht schief
let deg = rotation.to_degrees().rem_euclid(90.0);
if deg <= ELLIPSE_AXIS_SNAP_DEG || deg >= 90.0 - ELLIPSE_AXIS_SNAP_DEG {
rotation = (rotation.to_degrees() / 90.0).round() * 90.0_f32.to_radians();
}
let (sin, cos) = rotation.sin_cos();
let mut along: Vec<f32> = pts.iter().map(|p| p.x * cos + p.y * sin).collect();
let mut across: Vec<f32> = pts.iter().map(|p| -p.x * sin + p.y * cos).collect();
along.sort_by(f32::total_cmp);
across.sort_by(f32::total_cmp);
let lo = pts.len() * 2 / 100;
let hi = pts.len() - 1 - lo;
let (a0, a1, b0, b1) = (along[lo], along[hi], across[lo], across[hi]);
let mut radius_x = (a1 - a0) * 0.5;
let mut radius_y = (b1 - b0) * 0.5;
let center = Vec2::new(cos, sin) * (a0 + a1) * 0.5 + Vec2::new(-sin, cos) * (b0 + b1) * 0.5;
if radius_x < 10.0 || radius_y < 5.0 {
return None;
}
// Erste Halbachse ist die längere
if radius_y > radius_x {
std::mem::swap(&mut radius_x, &mut radius_y);
rotation += std::f32::consts::FRAC_PI_2;
}
if radius_y / radius_x >= ELLIPSE_MAX_ROUNDNESS {
return None;
}
// Drehwinkel auf (−90°, 90°] normieren
if rotation > std::f32::consts::FRAC_PI_2 {
rotation -= std::f32::consts::PI;
} else if rotation <= -std::f32::consts::FRAC_PI_2 {
rotation += std::f32::consts::PI;
}
Some(RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
})
}
/// Höchster Abstand der Widerhaken von der Spitze, als Anteil der Schaftlänge.
const ARROW_HEAD_REACH: f32 = 0.4;
/// Punkte so nahe an der Spitze gelten als Rückweg zur Spitze, nicht als Widerhaken.
const ARROW_TIP_ZONE: f32 = 0.08;
/// Pfeilerkennung (auch Doppelpfeil) für einen in einem Zug gezeichneten Pfeil: langer Schaft
/// und an mindestens einem Ende Widerhaken auf beiden Seiten, schräg nach hinten (10°–80°;
/// ein T-Ende mit 90° ist kein Pfeil).
pub fn detect_arrow(points: &[Vec2]) -> Option<RecognizedShape> {
if points.len() < 6 {
return None;
}
let total_len = path_length(points);
if total_len < 40.0 {
return None;
}
let epsilon = (total_len * 0.02).clamp(3.0, 15.0);
let simplified = ramer_douglas_peucker(points, epsilon);
if simplified.len() < 4 {
return None;
}
// Schaft = längstes Segment, an beiden Enden um fast gerade weiterlaufende Punkte
// verlängert (die Vereinfachung teilt den Schaft sonst gern kurz vor der Spitze)
let longest = (0..simplified.len() - 1).max_by(|&i, &j| {
let li = (simplified[i + 1] - simplified[i]).length();
let lj = (simplified[j + 1] - simplified[j]).length();
li.total_cmp(&lj)
})?;
let (mut first, mut last) = (longest, longest + 1);
// Ein Folgepunkt gehört zum Schaft, wenn er nahe der Schaftgeraden in Verlängerung liegt
// (kein Winkelkriterium: kurze Stücke haben durch das Zittern stark schwankende Richtungen)
let continues = |from: Vec2, to: Vec2, next: Vec2| {
let dir = (to - from).normalize_or_zero();
let beyond = (next - to).dot(dir) > 0.0;
let tolerance = (2.0 * epsilon).max(0.05 * (to - from).length());
beyond && point_to_line_dist(next, from, to) <= tolerance
};
while last + 1 < simplified.len()
&& continues(simplified[first], simplified[last], simplified[last + 1])
{
last += 1;
}
while first > 0 && continues(simplified[last], simplified[first], simplified[first - 1]) {
first -= 1;
}
let (a, b) = (simplified[first], simplified[last]);
let shaft_len = (b - a).length();
if shaft_len < 0.4 * total_len {
return None;
}
let others: Vec<Vec2> = simplified
.iter()
.enumerate()
.filter(|(i, _)| *i < first || *i > last)
.map(|(_, p)| *p)
.collect();
// Hat das Schaftende `tip` (mit Gegenende `tail`) Widerhaken auf beiden Seiten?
// Liefert None, wenn ein zugeordneter Punkt nicht wie ein Widerhaken liegt.
let head_at = |tip: Vec2, tail: Vec2, cluster: &[Vec2]| -> Option<bool> {
let back = (tail - tip).normalize_or_zero();
let (mut left, mut right) = (false, false);
for p in cluster {
let v = *p - tip;
if v.length() < ARROW_TIP_ZONE * shaft_len {
continue;
}
let angle = back.angle_between(v).to_degrees().abs();
if !(10.0..=80.0).contains(&angle) {
return None;
}
if back.perp_dot(v) > 0.0 {
left = true;
} else {
right = true;
}
}
Some(left && right)
};
// Übrige Punkte dem näheren Schaftende zuordnen; sie müssen nahe an diesem liegen
let (mut near_a, mut near_b) = (Vec::new(), Vec::new());
for p in others {
let (da, db) = ((p - a).length(), (p - b).length());
if da.min(db) > ARROW_HEAD_REACH * shaft_len {
return None;
}
if da < db {
near_a.push(p);
} else {
near_b.push(p);
}
}
let head_b = head_at(b, a, &near_b)?;
let head_a = head_at(a, b, &near_a)?;
let (start, end, double) = match (head_a, head_b) {
(true, true) => (a, b, true),
(false, true) if near_a.is_empty() => (a, b, false),
(true, false) if near_b.is_empty() => (b, a, false),
_ => return None,
};
// Fast waagerechte/senkrechte Pfeile (≤ 5°) gerade ausrichten
let dir = end - start;
let angle_deg = dir.y.atan2(dir.x).to_degrees().abs();
let end = if angle_deg < 5.0 || (angle_deg - 180.0).abs() < 5.0 {
Vec2::new(end.x, start.y)
} else if (angle_deg - 90.0).abs() < 5.0 {
Vec2::new(start.x, end.y)
} else {
end
};
Some(RecognizedShape::Arrow { start, end, double })
}
/// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss. /// Anteil der gesamten Richtungsänderung, der bei einem Vieleck in der Nähe der Ecken liegen muss.
const POLYGON_TURNING_AT_CORNERS: f32 = 0.7; const POLYGON_TURNING_AT_CORNERS: f32 = 0.7;
/// Umgebung einer Ecke als Anteil des Umfangs. /// Umgebung einer Ecke als Anteil des Umfangs.
@@ -930,21 +1200,7 @@ fn corner_turning_ratio(points: &[Vec2], corners: &[Vec2]) -> Option<f32> {
if total_len < 1e-3 || corners.is_empty() { if total_len < 1e-3 || corners.is_empty() {
return None; return None;
} }
// Gleichmäßig neu abtasten (120 Punkte entlang der Bogenlänge) let resampled = resample_path(points, 120);
const SAMPLES: usize = 120;
let step = total_len / SAMPLES as f32;
let mut resampled = vec![points[0]];
let mut carried = 0.0;
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
let seg = (b - a).length();
let mut pos = step - carried;
while pos <= seg {
resampled.push(a + (b - a) * (pos / seg));
pos += step;
}
carried = seg - (pos - step);
}
// Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten // Richtungen über einen Abstand von 3 Punkten, um das Zittern zu glätten
const W: usize = 3; const W: usize = 3;
if resampled.len() < 2 * W + 2 { if resampled.len() < 2 * W + 2 {
@@ -1177,9 +1433,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
}, },
&|| best_closed.clone(), &|| best_closed.clone(),
&|| detect_polygon(&pts), &|| detect_polygon(&pts),
// Pfeil vor Koordinatensystem und Gerade: Ein Pfeil ist eine Gerade mit Widerhaken
&|| detect_arrow(&pts),
&|| detect_coordinate_system(&pts), &|| detect_coordinate_system(&pts),
&|| detect_line(&pts), &|| detect_line(&pts),
&|| None,
]; ];
candidates candidates
.iter() .iter()
@@ -1229,8 +1486,12 @@ pub fn shape_fit_error(points: &[Vec2], shape: &RecognizedShape) -> Option<f32>
} }
/// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen? /// Beschreibt die Form den Strich gut genug, um ihn zu ersetzen?
/// Pfeile sind ausgenommen: Ihre Erkennung prüft die Struktur (Schaft, Widerhaken auf beiden
/// Seiten) bereits streng, und gezeichnete Widerhaken weichen naturgemäß von der sauberen
/// Spitze ab.
fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool { fn shape_describes_stroke(points: &[Vec2], shape: &RecognizedShape) -> bool {
shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR) matches!(shape, RecognizedShape::Arrow { .. })
|| shape_fit_error(points, shape).is_some_and(|err| err <= SHAPE_MAX_FIT_ERROR)
} }
/// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale. /// Größte zulässige Abweichung (RMS) eines Rechtecks vom Strich, als Anteil der Diagonale.
@@ -1248,7 +1509,7 @@ const CLOSED_SHAPE_GOOD_FIT: f32 = 0.04;
const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5; const RECTANGLE_WEIGHT: f32 = 1.0 / 1.5;
const CIRCLE_WEIGHT: f32 = 1.5; const CIRCLE_WEIGHT: f32 = 1.5;
/// Wählt unter Kreis, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt, /// Wählt unter Kreis, Ellipse, Dreieck, Rechteck, Raute und Vieleck die Form, deren Kontur am besten passt,
/// und liefert sie mit ihrer Abweichung (RMS). /// und liefert sie mit ihrer Abweichung (RMS).
fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> { fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
// Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht. // Gewichtete Abweichung: Bei fast gleicher Passung gewinnt die wahrscheinlichere Absicht.
@@ -1259,6 +1520,7 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
}; };
[ [
fit(detect_circle(pts), CIRCLE_WEIGHT), fit(detect_circle(pts), CIRCLE_WEIGHT),
fit(detect_ellipse(pts), 1.0),
fit(detect_triangle(pts), 1.0), fit(detect_triangle(pts), 1.0),
fit(detect_rectangle(pts), RECTANGLE_WEIGHT), fit(detect_rectangle(pts), RECTANGLE_WEIGHT),
fit(detect_rhombus(pts), 1.0), fit(detect_rhombus(pts), 1.0),
@@ -1269,6 +1531,45 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> {
.min_by(|a, b| a.0.total_cmp(&b.0)) .min_by(|a, b| a.0.total_cmp(&b.0))
} }
/// Halber Öffnungswinkel einer Pfeilspitze als Verhältnis halbe Breite zu Länge (tan 20° ≈ 0,36,
/// also ~40° Öffnung – schlank genug, dass die Spitze spitz wirkt).
const ARROW_HALF_WIDTH_RATIO: f32 = 0.36;
/// Geometrie einer Pfeilspitze an `tip`, Richtung `dir` (Einheitsvektor), für eine Linie der
/// halben Breite `half_w` (Seiteneinheiten) und der Länge `line_len`. Liefert (Basismitte,
/// linke Ecke, rechte Ecke). Die Spitze ist ~4 mm lang (dicke Linien länger), höchstens ein
/// Fünftel der Linie. Die Linie soll an der Basismitte enden – reicht sie bis zur Spitze,
/// wirkt diese stumpf.
pub fn arrow_head(tip: Vec2, dir: Vec2, half_w: f32, line_len: f32) -> (Vec2, Vec2, Vec2) {
let len = (half_w * 10.0).max(40.0).min(line_len * 0.2);
let half_width = len * ARROW_HALF_WIDTH_RATIO;
let base = tip - dir * len;
let normal = Vec2::new(-dir.y, dir.x);
(base, base + normal * half_width, base - normal * half_width)
}
/// Punkt auf einer Ellipse zum Parameter `t` (Bogenmaß).
pub fn ellipse_point(center: Vec2, radius_x: f32, radius_y: f32, rotation: f32, t: f32) -> Vec2 {
let (sin, cos) = rotation.sin_cos();
let local = Vec2::new(t.cos() * radius_x, t.sin() * radius_y);
center + Vec2::new(local.x * cos - local.y * sin, local.x * sin + local.y * cos)
}
/// Näherungsweiser Abstand eines Punktes zum Rand einer Ellipse: Abstand zum Mittelpunkt
/// minus Ellipsenradius in dieser Richtung (für die Formauswahl genau genug).
fn ellipse_distance(p: Vec2, center: Vec2, radius_x: f32, radius_y: f32, rotation: f32) -> f32 {
let d = p - center;
let (sin, cos) = rotation.sin_cos();
let local = Vec2::new(d.x * cos + d.y * sin, -d.x * sin + d.y * cos);
let scaled = Vec2::new(local.x / radius_x.max(1e-3), local.y / radius_y.max(1e-3));
let scaled_len = scaled.length();
if scaled_len < 1e-6 {
return radius_x.min(radius_y);
}
let radius_in_direction = local.length() / scaled_len;
(local.length() - radius_in_direction).abs()
}
/// Abstand eines Punktes zur Strecke `a`–`b`. /// Abstand eines Punktes zur Strecke `a`–`b`.
fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 { fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 {
let ab = b - a; let ab = b - a;
@@ -1299,6 +1600,14 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
RecognizedShape::Circle { center, radius } => { RecognizedShape::Circle { center, radius } => {
Some(((p - *center).length() - radius).abs()) Some(((p - *center).length() - radius).abs())
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => Some(ellipse_distance(
p, *center, *radius_x, *radius_y, *rotation,
)),
RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)), RecognizedShape::Triangle { corners } => Some(polygon_distance(p, corners)),
RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => { RecognizedShape::Rectangle { corners } | RecognizedShape::Rhombus { corners } => {
Some(polygon_distance(p, corners)) Some(polygon_distance(p, corners))
@@ -1442,6 +1751,70 @@ pub fn shape_to_mesh(
(samples, vertices) (samples, vertices)
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
const SEGMENTS: usize = 96;
let outline: Vec<Vec2> = (0..SEGMENTS)
.map(|i| {
let t = i as f32 / SEGMENTS as f32 * 2.0 * PI;
ellipse_point(*center, *radius_x, *radius_y, *rotation, t)
})
.collect();
closed_polygon_to_mesh(&outline, half_w, color)
}
RecognizedShape::Arrow { start, end, double } => {
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = arrow_head(*end, dir, half_w, shaft_len);
let start_head = arrow_head(*start, -dir, half_w, shaft_len);
// Der Schaft endet an der Basis der Spitze(n), damit diese spitz bleiben
let line_start = if *double { start_head.0 } else { *start };
let line_end = end_head.0;
let norm = Vec2::new(-dir.y, dir.x) * half_w;
let mut vertices = vec![
InkVertex::new(line_start.x + norm.x, line_start.y + norm.y, color),
InkVertex::new(line_start.x - norm.x, line_start.y - norm.y, color),
InkVertex::new(line_end.x + norm.x, line_end.y + norm.y, color),
InkVertex::new(line_end.x - norm.x, line_end.y - norm.y, color),
];
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in &heads {
let last_v = *vertices.last().unwrap();
let tip_v = InkVertex::new(tip.x, tip.y, color);
vertices.push(last_v);
vertices.push(tip_v);
vertices.push(tip_v);
vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(right.x, right.y, color));
}
// Abtastpunkte als zusammenhängender Linienzug: (Spitze am Start,) Schaft, Spitze am Ende
let sample = |p: Vec2| StylusSample {
position: p,
pressure: 0.5,
timestamp: 0.0,
};
let mut samples = Vec::new();
if *double {
let (_, l, r) = start_head;
samples.extend([*start, l, r, *start].map(sample));
}
let steps = ((shaft_len / 5.0).ceil() as usize).max(2);
samples.extend((0..=steps).map(|i| sample(start.lerp(*end, i as f32 / steps as f32))));
let (_, l, r) = end_head;
samples.extend([l, r, *end].map(sample));
(samples, vertices)
}
RecognizedShape::Circle { center, radius } => { RecognizedShape::Circle { center, radius } => {
let segments = 64; let segments = 64;
let r_in = (radius - half_w).max(0.1); let r_in = (radius - half_w).max(0.1);
@@ -1539,11 +1912,20 @@ pub fn shape_to_mesh(
let mut vertices = Vec::new(); let mut vertices = Vec::new();
let mut samples = Vec::new(); let mut samples = Vec::new();
// 1. Y-Achse (von Pfeilspitze oben zum Ursprung) // Achsen enden bei Pfeilen an der Basis der Spitze, sonst überdeckt das Linienband
// die Spitze und sie wirkt stumpf
let y_dir = (*y_axis_end - *origin).normalize_or_zero(); let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w; let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = arrow_head(*y_axis_end, y_dir, half_w, (*y_axis_end - *origin).length());
let x_arrow = arrow_head(*x_axis_end, x_dir, half_w, (*x_axis_end - *origin).length());
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
// Y-Linienband // 1. Y-Achse (vom Ursprung zur Spitze)
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
origin.x + y_norm.x, origin.x + y_norm.x,
origin.y + y_norm.y, origin.y + y_norm.y,
@@ -1555,26 +1937,24 @@ pub fn shape_to_mesh(
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
y_axis_end.x + y_norm.x, y_line_end.x + y_norm.x,
y_axis_end.y + y_norm.y, y_line_end.y + y_norm.y,
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
y_axis_end.x - y_norm.x, y_line_end.x - y_norm.x,
y_axis_end.y - y_norm.y, y_line_end.y - y_norm.y,
color, color,
)); ));
// Degenerate Dreiecke für Übergang zur X-Achse // Degenerierte Dreiecke für den Übergang zur X-Achse
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w; let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
let last_v = *vertices.last().unwrap(); let last_v = *vertices.last().unwrap();
let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color); let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color);
vertices.push(last_v); vertices.push(last_v);
vertices.push(next_v); vertices.push(next_v);
// 2. X-Linienband // 2. X-Achse
vertices.push(next_v); vertices.push(next_v);
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
origin.x - x_norm.x, origin.x - x_norm.x,
@@ -1582,48 +1962,27 @@ pub fn shape_to_mesh(
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
x_axis_end.x + x_norm.x, x_line_end.x + x_norm.x,
x_axis_end.y + x_norm.y, x_line_end.y + x_norm.y,
color, color,
)); ));
vertices.push(InkVertex::new( vertices.push(InkVertex::new(
x_axis_end.x - x_norm.x, x_line_end.x - x_norm.x,
x_axis_end.y - x_norm.y, x_line_end.y - x_norm.y,
color, color,
)); ));
// 3. Optionale Pfeilspitzen // 3. Optionale Pfeilspitzen (je ein Dreieck, über degenerierte Dreiecke angehängt)
if *with_arrows { if *with_arrows {
let arrow_len = (half_w * 5.0).max(18.0); for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let arrow_w = (half_w * 3.5).max(12.0); let last_v = *vertices.last().unwrap();
let tip_v = InkVertex::new(tip.x, tip.y, color);
// Pfeilspitze Y vertices.push(last_v);
let y_base = *y_axis_end - y_dir * arrow_len; vertices.push(tip_v);
let y_left = y_base + Vec2::new(-y_dir.y, y_dir.x) * arrow_w; vertices.push(tip_v);
let y_right = y_base - Vec2::new(-y_dir.y, y_dir.x) * arrow_w; vertices.push(InkVertex::new(left.x, left.y, color));
vertices.push(InkVertex::new(right.x, right.y, color));
let last_v = *vertices.last().unwrap(); }
let tip_v = InkVertex::new(y_axis_end.x, y_axis_end.y, color);
vertices.push(last_v);
vertices.push(tip_v);
vertices.push(tip_v);
vertices.push(InkVertex::new(y_left.x, y_left.y, color));
vertices.push(InkVertex::new(y_right.x, y_right.y, color));
// Pfeilspitze X
let x_base = *x_axis_end - x_dir * arrow_len;
let x_left = x_base + Vec2::new(-x_dir.y, x_dir.x) * arrow_w;
let x_right = x_base - Vec2::new(-x_dir.y, x_dir.x) * arrow_w;
let last_v = *vertices.last().unwrap();
let tip_x = InkVertex::new(x_axis_end.x, x_axis_end.y, color);
vertices.push(last_v);
vertices.push(tip_x);
vertices.push(tip_x);
vertices.push(InkVertex::new(x_left.x, x_left.y, color));
vertices.push(InkVertex::new(x_right.x, x_right.y, color));
} }
// Abtastpunkte // Abtastpunkte
@@ -2084,6 +2443,9 @@ mod tests {
match shape { match shape {
Some(RecognizedShape::Line { .. }) => "Linie", Some(RecognizedShape::Line { .. }) => "Linie",
Some(RecognizedShape::Circle { .. }) => "Kreis", Some(RecognizedShape::Circle { .. }) => "Kreis",
Some(RecognizedShape::Ellipse { .. }) => "Ellipse",
Some(RecognizedShape::Arrow { double: false, .. }) => "Pfeil",
Some(RecognizedShape::Arrow { double: true, .. }) => "Doppelpfeil",
Some(RecognizedShape::Rectangle { .. }) => "Rechteck", Some(RecognizedShape::Rectangle { .. }) => "Rechteck",
Some(RecognizedShape::Triangle { .. }) => "Dreieck", Some(RecognizedShape::Triangle { .. }) => "Dreieck",
Some(RecognizedShape::Rhombus { .. }) => "Raute", Some(RecognizedShape::Rhombus { .. }) => "Raute",
@@ -2288,6 +2650,57 @@ mod tests {
add("Oval 0.6".to_string(), kind(&detect_shape(&pts))); add("Oval 0.6".to_string(), kind(&detect_shape(&pts)));
} }
} }
// Pfeile in einem Zug: Schaft, dann Widerhaken links, zurück zur Spitze, Widerhaken rechts
for &len in &[200.0_f32, 400.0, 700.0] {
for &angle in &[0.0_f32, 30.0, 135.0, 260.0] {
for &barb_deg in &[25.0_f32, 40.0, 55.0] {
for &seed in &seeds[..3] {
let dir = Vec2::from_angle(angle.to_radians());
let tail = c - dir * len * 0.5;
let tip = c + dir * len * 0.5;
let barb = |side: f32| {
tip + Vec2::from_angle(side * barb_deg.to_radians()).rotate(-dir)
* len
* 0.15
};
let single = [tail, tip, barb(1.0), tip, barb(-1.0)];
add(
format!("Pfeil {barb_deg}°"),
kind(&detect_shape(&freehand(&single, false, seed))),
);
let barb_tail = |side: f32| {
tail + Vec2::from_angle(side * barb_deg.to_radians()).rotate(dir)
* len
* 0.15
};
let double = [
barb_tail(1.0),
tail,
barb_tail(-1.0),
tail,
tip,
barb(1.0),
tip,
barb(-1.0),
];
add(
format!("Doppelpfeil {barb_deg}°"),
kind(&detect_shape(&freehand(&double, false, seed))),
);
}
}
// Gerade bleibt Gerade
let dir = Vec2::from_angle(angle.to_radians());
add(
"Linie".to_string(),
kind(&detect_shape(&freehand(
&[c - dir * len * 0.5, c + dir * len * 0.5],
false,
0.3,
))),
);
}
}
// Freiformen, die KEIN Rechteck/Kreis werden sollen // Freiformen, die KEIN Rechteck/Kreis werden sollen
for &seed in &seeds { for &seed in &seeds {
let star: Vec<Vec2> = (0..10) let star: Vec<Vec2> = (0..10)
@@ -2349,8 +2762,19 @@ mod tests {
for group in stats.keys() { for group in stats.keys() {
let expected = if group.starts_with("Rechteck") { let expected = if group.starts_with("Rechteck") {
Some("Rechteck") Some("Rechteck")
} else if group == "Kreis/Ellipse 1" || group == "Kreis/Ellipse 0.8" { } else if group == "Kreis/Ellipse 1" {
Some("Kreis") Some("Kreis")
} else if group == "Kreis/Ellipse 0.8"
|| group == "Kreis/Ellipse 0.6"
|| group == "Oval 0.6"
{
Some("Ellipse")
} else if group.starts_with("Pfeil") {
Some("Pfeil")
} else if group.starts_with("Doppelpfeil") {
Some("Doppelpfeil")
} else if group.starts_with("Linie") {
Some("Linie")
} else if group == "Dreieck" { } else if group == "Dreieck" {
Some("Dreieck") Some("Dreieck")
} else if group.starts_with("Vieleck") { } else if group.starts_with("Vieleck") {
@@ -2364,7 +2788,7 @@ mod tests {
if let Some(kind) = expected { if let Some(kind) = expected {
let (hits, total) = count(group, kind); let (hits, total) = count(group, kind);
// Achteck/Sechseck und leicht ovale Kreise sind von Hand kaum zu unterscheiden // Achteck/Sechseck und leicht ovale Kreise sind von Hand kaum zu unterscheiden
let min_share = if group.starts_with("Vieleck") || group == "Kreis/Ellipse 0.8" { let min_share = if group.starts_with("Vieleck") {
0.9 0.9
} else { } else {
1.0 1.0
@@ -2400,4 +2824,145 @@ mod tests {
}) })
.collect() .collect()
} }
#[test]
fn test_arrow_head_is_slender_and_axes_end_at_its_base() {
// Spitze schlank (~40° Öffnung) und breiter als die Linie
let (base, left, right) = arrow_head(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0), 2.5, 500.0);
let len = base.length();
let half_width = (left - base).length();
let opening = 2.0 * (half_width / len).atan().to_degrees();
assert!(
(35.0..=45.0).contains(&opening),
"Öffnungswinkel {opening}°"
);
assert!(
half_width > 2.5 * 2.0,
"Spitze muss breiter als die Linie sein"
);
assert!(((left + right) * 0.5 - base).length() < 1e-4);
assert!(len >= 40.0, "Spitze mindestens 4 mm lang: {len}");
// Bei kurzen Linien höchstens ein Fünftel der Linie
let (short_base, ..) = arrow_head(Vec2::ZERO, Vec2::new(1.0, 0.0), 2.5, 100.0);
assert!((short_base.length() - 20.0).abs() < 1e-3);
// Kein Vertex des Linienbands ragt über die Basis hinaus in die Spitze hinein
let origin = Vec2::new(100.0, 300.0);
let y_end = Vec2::new(100.0, 100.0);
let x_end = Vec2::new(300.0, 300.0);
let shape = RecognizedShape::CoordinateSystem {
origin,
x_axis_end: x_end,
y_axis_end: y_end,
with_arrows: true,
};
let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]);
let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0);
let (x_base, ..) = arrow_head(x_end, Vec2::new(1.0, 0.0), 2.5, 200.0);
for v in &vertices {
let p = Vec2::new(v.position[0], v.position[1]);
let is_tip = (p - y_end).length() < 1e-3 || (p - x_end).length() < 1e-3;
assert!(
is_tip || p.y >= y_base.y - 1e-3,
"Y-Achse ragt in die Spitze: {p:?}"
);
assert!(
is_tip || p.x <= x_base.x + 1e-3,
"X-Achse ragt in die Spitze: {p:?}"
);
}
}
#[test]
fn test_arrow_negative_cases() {
let c = Vec2::new(1000.0, 1400.0);
// T-Ende: Querbalken im rechten Winkel ist kein Pfeil
let t_shape = [
c - Vec2::new(300.0, 0.0),
c + Vec2::new(300.0, 0.0),
c + Vec2::new(300.0, 60.0),
c + Vec2::new(300.0, -60.0),
];
assert_ne!(
kind(&detect_shape(&freehand(&t_shape, false, 0.4))),
"Pfeil"
);
// Zickzack ist kein Pfeil
let zigzag: Vec<Vec2> = (0..8)
.map(|i| c + Vec2::new(i as f32 * 80.0, if i % 2 == 0 { 0.0 } else { 60.0 }))
.collect();
let got = kind(&detect_shape(&freehand(&zigzag, false, 0.4)));
assert!(
got != "Pfeil" && got != "Doppelpfeil",
"Zickzack erkannt als {got}"
);
}
#[test]
fn test_ellipse_and_arrow_transforms_and_mesh() {
let mut ellipse = RecognizedShape::Ellipse {
center: Vec2::new(500.0, 500.0),
radius_x: 200.0,
radius_y: 100.0,
rotation: 0.0,
};
ellipse.translate(Vec2::new(10.0, 20.0));
ellipse.rotate(Vec2::new(510.0, 520.0), std::f32::consts::FRAC_PI_2);
ellipse.scale(Vec2::new(510.0, 520.0), Vec2::new(2.0, 1.0));
if let RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} = ellipse
{
assert!((center - Vec2::new(510.0, 520.0)).length() < 1e-3);
// Nach 90°-Drehung liegt die lange Achse senkrecht; die x-Skalierung trifft die kurze
assert!((radius_x - 200.0).abs() < 1e-2, "radius_x {radius_x}");
assert!((radius_y - 200.0).abs() < 1e-2, "radius_y {radius_y}");
assert!((rotation.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3);
} else {
panic!("Ellipse erwartet");
}
// Ellipsen-Mesh: alle Abtastpunkte liegen auf der Ellipse
let shape = RecognizedShape::Ellipse {
center: Vec2::new(0.0, 0.0),
radius_x: 300.0,
radius_y: 120.0,
rotation: 0.5,
};
let (samples, vertices) = shape_to_mesh(&shape, 5.0, [0.0; 4]);
assert!(!vertices.is_empty());
let pts: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
assert!(contour_rms(&pts, &shape).unwrap() < 1.0);
// Pfeil: Verschieben/Skalieren bewegt beide Enden, Mesh hat Spitze(n)
let mut arrow = RecognizedShape::Arrow {
start: Vec2::new(0.0, 0.0),
end: Vec2::new(100.0, 0.0),
double: true,
};
arrow.translate(Vec2::new(5.0, 5.0));
arrow.scale(Vec2::new(5.0, 5.0), Vec2::new(2.0, 2.0));
assert_eq!(
arrow,
RecognizedShape::Arrow {
start: Vec2::new(5.0, 5.0),
end: Vec2::new(205.0, 5.0),
double: true
}
);
let (_, single_v) = shape_to_mesh(
&RecognizedShape::Arrow {
start: Vec2::ZERO,
end: Vec2::new(300.0, 0.0),
double: false,
},
5.0,
[0.0; 4],
);
let (_, double_v) = shape_to_mesh(&arrow, 5.0, [0.0; 4]);
assert_eq!(double_v.len(), single_v.len() + 5, "zweite Spitze fehlt");
}
} }
+53
View File
@@ -18,6 +18,13 @@ pub enum HistoryAction {
before: Vec<Stroke>, before: Vec<Stroke>,
after: 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). /// History-Stack für Transaktionsverwaltung (Undo / Redo).
@@ -41,6 +48,40 @@ impl HistoryStack {
self.redo_stack.clear(); 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. /// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool { pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty() !self.undo_stack.is_empty()
@@ -95,6 +136,12 @@ impl HistoryStack {
self.redo_stack self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .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 true
@@ -140,6 +187,12 @@ impl HistoryStack {
self.undo_stack self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .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 true
+408
View File
@@ -0,0 +1,408 @@
//! 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,
}
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,
}
}
/// Ü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()
}
/// 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, _)| key)
.filter(|key| !self.textures.contains_key(*key))
.cloned()
.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 {
let used: HashSet<&String> = self.groups.iter().flatten().map(|(a, _)| a).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);
}
}
/// 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(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(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);
}
}
}
+8
View File
@@ -1,13 +1,21 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux. //! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod assets;
pub mod autosave; pub mod autosave;
pub mod background; pub mod background;
pub mod canvas; pub mod canvas;
pub mod document; pub mod document;
pub mod export; pub mod export;
pub mod file_picker;
pub mod filter; pub mod filter;
pub mod geometry; pub mod geometry;
pub mod history; 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 selection;
pub mod settings; pub mod settings;
pub mod spline; pub mod spline;
+764 -85
View File
File diff suppressed because it is too large Load Diff
+233
View File
@@ -0,0 +1,233 @@
//! 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>"));
}
#[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());
}
}
+291
View File
@@ -0,0 +1,291 @@
//! 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;
/// 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,
}
/// 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, DISPLAY_RENDER_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);
}
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) -> bool {
if !self.pending.insert(key.to_string()) {
return true;
}
let request = RenderRequest {
key: key.to_string(),
path,
page,
};
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`.
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> {
self.collect();
if let Some(result) = self.ready.remove(key) {
return Some(result);
}
if !self.request(key, path, page) {
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(());
});
assert!(service.take("a#0", path.clone(), 0).is_none());
// Zweite Anfrage erteilt keinen weiteren Auftrag
assert!(service.take("a#0", path.clone(), 0).is_none());
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
let image = service.take("a#0", path.clone(), 0).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).unwrap().is_ok());
}
}
+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() { if self.selected_ids.is_empty() {
return false; 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() { if strokes.is_empty() {
return false; return false;
} }
@@ -499,4 +504,30 @@ mod tests {
let restored_pos = page.strokes[0].points[0].position; let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); 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 serde::{Deserialize, Serialize};
use crate::background::PaperTheme;
use crate::storage::{self, StorageError}; use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist. /// Fensterseite, an der die Werkzeugleiste angedockt ist.
@@ -18,11 +19,85 @@ pub enum SidebarSide {
Right, 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. /// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct UiSettings { pub struct UiSettings {
pub sidebar_side: SidebarSide, 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 { impl UiSettings {
@@ -37,7 +112,7 @@ impl UiSettings {
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte. /// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self { 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| { Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!( eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.", "[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
@@ -46,7 +121,9 @@ impl UiSettings {
Self::default() Self::default()
}), }),
Err(_) => Self::default(), Err(_) => Self::default(),
} };
settings.pressure_curve = settings.pressure_curve.sanitized();
settings
} }
/// Speichert die Einstellungen atomar. /// Speichert die Einstellungen atomar.
@@ -69,6 +146,13 @@ mod tests {
let settings = UiSettings { let settings = UiSettings {
sidebar_side: SidebarSide::Right, 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(); settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings); assert_eq!(UiSettings::load_from(&path), settings);
@@ -81,4 +165,71 @@ mod tests {
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap(); std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default()); 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 { struct CameraUniforms {
transform_scale: vec2<f32>, transform_scale: vec2<f32>,
transform_offset: 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) @group(0) @binding(0)
@@ -32,3 +34,46 @@ fn vs_main(in: VertexInput) -> VertexOutput {
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color; 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);
}
+317 -7
View File
@@ -70,6 +70,9 @@ pub struct StrokeData {
pub width: f32, pub width: f32,
#[serde(default = "default_finalized")] #[serde(default = "default_finalized")]
pub finalized: bool, pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner); die Samples sind dann die vier Bildecken
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
} }
impl From<&Stroke> for StrokeData { impl From<&Stroke> for StrokeData {
@@ -82,6 +85,7 @@ impl From<&Stroke> for StrokeData {
tool: stroke.tool, tool: stroke.tool,
width: stroke.width, width: stroke.width,
finalized: stroke.finalized, finalized: stroke.finalized,
image: stroke.image.clone(),
} }
} }
} }
@@ -96,6 +100,17 @@ impl StrokeData {
.copied() .copied()
.map(StylusSample::from) .map(StylusSample::from)
.collect(); .collect();
if let Some(asset) = &self.image {
if let [a, b, c, d, ..] = stylus_samples.as_slice() {
let mut stroke = Stroke::new_image(
self.id,
asset.clone(),
[a.position, b.position, c.position, d.position],
);
stroke.color = self.color;
return stroke;
}
}
let width = if self.width > 0.0 { let width = if self.width > 0.0 {
self.width self.width
} else if self.shape.is_some() { } else if self.shape.is_some() {
@@ -117,15 +132,28 @@ impl StrokeData {
}; };
let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color); let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color);
stroke.shape = self.shape.clone(); 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.width = width;
stroke.finalized = self.finalized; stroke.finalized = self.finalized;
stroke stroke
} }
} }
/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized). /// Aktuelle Dateiformat-Version von ink-notes.
pub const CURRENT_FORMAT_VERSION: u32 = 2; /// - 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).
pub const CURRENT_FORMAT_VERSION: u32 = 6;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -139,6 +167,12 @@ pub struct PageData {
pub format_version: u32, pub format_version: u32,
pub id: u64, pub id: u64,
pub background: PageBackground, 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>,
pub strokes: Vec<StrokeData>, pub strokes: Vec<StrokeData>,
} }
@@ -148,6 +182,8 @@ impl From<&Page> for PageData {
format_version: CURRENT_FORMAT_VERSION, format_version: CURRENT_FORMAT_VERSION,
id: page.id, id: page.id,
background: page.background, background: page.background,
pdf_background: page.pdf_background.clone(),
title: page.title.clone(),
strokes: page.strokes.iter().map(StrokeData::from).collect(), strokes: page.strokes.iter().map(StrokeData::from).collect(),
} }
} }
@@ -157,6 +193,8 @@ impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um. /// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page { pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background); let mut page = Page::new(self.id, self.background);
page.pdf_background = self.pdf_background.clone();
page.title = self.title.clone();
for stroke_data in &self.strokes { for stroke_data in &self.strokes {
page.strokes.push(stroke_data.to_stroke()); page.strokes.push(stroke_data.to_stroke());
} }
@@ -173,11 +211,18 @@ pub struct PageMeta {
pub id: u64, pub id: u64,
#[serde(default)] #[serde(default)]
pub background: Option<PageBackground>, 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>,
} }
impl PageMeta { impl PageMeta {
pub fn new(id: u64, background: Option<PageBackground>) -> Self { pub fn new(id: u64, background: Option<PageBackground>) -> Self {
Self { id, background } Self {
id,
background,
title: None,
}
} }
} }
@@ -227,6 +272,13 @@ 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 background_for_page(&self, page_id: u64) -> Option<PageBackground> { pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
self.pages self.pages
.iter() .iter()
@@ -242,6 +294,7 @@ impl NotebookManifest {
.map(|p| PageMeta { .map(|p| PageMeta {
id: p.id, id: p.id,
background: Some(p.background), background: Some(p.background),
title: p.title.clone(),
}) })
.collect(); .collect();
self.page_ids = self.pages.iter().map(|p| p.id).collect(); self.page_ids = self.pages.iter().map(|p| p.id).collect();
@@ -689,6 +742,13 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
pages.push(Page::new_unloaded(page_id, bg)); pages.push(Page::new_unloaded(page_id, bg));
} }
// 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( let mut notebook = Notebook::from_pages(
pages, pages,
manifest.current_page_index, manifest.current_page_index,
@@ -726,7 +786,18 @@ pub fn ensure_page_loaded(
let page_id = notebook.pages[page_idx].id; let page_id = notebook.pages[page_idx].id;
match load_page(notebook_dir, page_id) { match load_page(notebook_dir, page_id) {
Ok(data) => { 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);
page.title = placeholder.title.take();
*placeholder = page;
Ok(()) Ok(())
} }
Err(StorageError::CorruptedPage(id, err_msg)) => { Err(StorageError::CorruptedPage(id, err_msg)) => {
@@ -1030,6 +1101,50 @@ pub fn create_new_notebook(
Ok((notebook, manifest, notebook_dir)) 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);
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. /// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
pub fn rename_notebook( pub fn rename_notebook(
notebook_dir: &Path, notebook_dir: &Path,
@@ -1226,12 +1341,14 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
)); ));
} }
if !page.is_loaded { if !page.is_loaded {
let title = page.title.take();
*page = match load_page(notebook_dir, page.id) { *page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(), Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite // 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),
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
page.title = page.title.take().or(title);
} }
} }
Ok(()) Ok(())
@@ -2169,12 +2286,12 @@ mod tests {
let (mut loaded, _) = load_notebook(&dir).unwrap(); let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded); assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen // 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(); load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1); assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined); 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] #[test]
@@ -2209,6 +2326,26 @@ mod tests {
assert_eq!(reloaded.current_page().strokes.len(), 2); 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] #[test]
fn test_autosave_never_writes_unloaded_placeholder() { fn test_autosave_never_writes_unloaded_placeholder() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
@@ -2233,4 +2370,177 @@ mod tests {
"Seitendatei wurde überschrieben" "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_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);
}
} }
+1036 -41
View File
File diff suppressed because it is too large Load Diff
+132 -1
View File
@@ -3,13 +3,54 @@
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel), //! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]). //! 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, PAGE_HEIGHT, PAGE_WIDTH, UNITS_PER_MM};
use glam::{Mat4, Vec2, Vec4}; use glam::{Mat4, Vec2, Vec4};
/// Minimaler und maximaler Zoomfaktor. /// Minimaler und maximaler Zoomfaktor.
pub const MIN_ZOOM: f32 = 0.25; pub const MIN_ZOOM: f32 = 0.25;
pub const MAX_ZOOM: f32 = 5.0; pub const MAX_ZOOM: f32 = 5.0;
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
/// Vertikaler Abstand gleicher Punkte zweier aufeinanderfolgender Seiten.
pub const PAGE_PITCH: f32 = PAGE_HEIGHT + PAGE_GAP;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen untereinander, Koordinaten beziehen sich auf die
/// aktuelle Seite. Liefert den Versatz (in Seiten) der Seite, auf der die Höhe `y` liegt,
/// oder `None` im Zwischenraum zweier Seiten.
pub fn page_offset_at(y: f32) -> Option<i64> {
let k = (y / PAGE_PITCH).floor();
(y - k * PAGE_PITCH <= PAGE_HEIGHT).then_some(k as i64)
}
/// Versätze der Seiten, die zwischen den Höhen `top` und `bottom` (Koordinaten der aktuellen
/// Seite) sichtbar sind, begrenzt auf die Seitenindizes `0..page_count` und ohne die aktuelle
/// Seite selbst.
pub fn visible_neighbor_offsets(
top: f32,
bottom: f32,
current: usize,
page_count: usize,
) -> Vec<i64> {
if !(top.is_finite() && bottom.is_finite()) || bottom < top {
return Vec::new();
}
let first = (top / PAGE_PITCH).floor().max(-(current as f32)) as i64;
let last = (bottom / PAGE_PITCH)
.floor()
.min((page_count as f32) - 1.0 - current as f32) as i64;
let mut offsets: Vec<i64> = (first..=last).filter(|&k| k != 0).collect();
// Sehr weit herausgezoomt: nur die nächstgelegenen Seiten
if offsets.len() > MAX_NEIGHBOR_PAGES {
offsets.sort_by_key(|k| k.unsigned_abs());
offsets.truncate(MAX_NEIGHBOR_PAGES);
offsets.sort_unstable();
}
offsets
}
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan. /// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct ViewportState { pub struct ViewportState {
@@ -39,6 +80,35 @@ impl ViewportState {
self.pan_offset = [0.0, 0.0]; self.pan_offset = [0.0, 0.0];
} }
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich
/// der Bildausschnitt bewegt.
pub fn shift_reference_page(&mut self, k: i64) {
self.pan_offset[1] += k as f32 * PAGE_PITCH;
}
/// 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],
current: usize,
page_count: usize,
page_viewport: &PageViewport,
) -> bool {
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
let top = -(current as f32) * PAGE_PITCH;
let bottom = page_count.saturating_sub(1 + current) as f32 * PAGE_PITCH + PAGE_HEIGHT;
let clamped = Vec2::new(c.x.clamp(0.0, PAGE_WIDTH), c.y.clamp(top, bottom.max(top)));
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. /// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
pub fn pan_by_screen_delta( pub fn pan_by_screen_delta(
&mut self, &mut self,
@@ -190,6 +260,67 @@ impl ViewportState {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_continuous_layout() {
assert_eq!(page_offset_at(100.0), Some(0));
assert_eq!(page_offset_at(PAGE_HEIGHT + PAGE_GAP / 2.0), None);
assert_eq!(page_offset_at(PAGE_PITCH + 5.0), Some(1));
assert_eq!(page_offset_at(-5.0), None);
assert_eq!(page_offset_at(-PAGE_GAP - 5.0), Some(-1));
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
assert_eq!(
visible_neighbor_offsets(PAGE_HEIGHT - 500.0, PAGE_PITCH + 500.0, 1, 5),
vec![1]
);
// Ganz oben im Notizbuch: keine Seite vor der ersten
assert!(visible_neighbor_offsets(-PAGE_PITCH * 3.0, 500.0, 0, 5).is_empty());
// Hinter der letzten Seite: keine weiteren
assert_eq!(
visible_neighbor_offsets(-PAGE_PITCH, PAGE_PITCH * 4.0, 3, 5),
vec![-1, 1]
);
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
let many = visible_neighbor_offsets(-PAGE_PITCH * 50.0, PAGE_PITCH * 50.0, 50, 200);
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
assert!(many.iter().all(|k| k.unsigned_abs() <= 6));
}
#[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, PAGE_PITCH + 200.0, &page_vp);
vp.shift_reference_page(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_continuous_scroll_stays_on_the_notebook() {
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
let center = [640.0, 425.0];
let mut vp = ViewportState::new();
// Mitte der Seite: nichts zu korrigieren
assert!(!vp.clamp_continuous_center(center, 1, 3, &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, 1, 3, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!(
(c.y + PAGE_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, 1, 3, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!((c.y - (PAGE_PITCH + PAGE_HEIGHT)).abs() < 0.1, "{c:?}");
assert!((c.x - PAGE_WIDTH).abs() < 0.1, "{c:?}");
}
#[test] #[test]
fn test_viewport_zoom_limits() { fn test_viewport_zoom_limits() {
let mut vp = ViewportState::new(); let mut vp = ViewportState::new();