Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb190cf79 | ||
|
|
1148851585 | ||
|
|
eef4cc6b2b | ||
|
|
dc067af31e | ||
|
|
693f62c2e7 | ||
|
|
f212b2e23f | ||
|
|
61c0c903a4 | ||
|
|
680fa96f32 | ||
|
|
eb0a801921 | ||
|
|
76ecdac081 | ||
|
|
ea9ee6c0d7 | ||
|
|
d1f0b46fac | ||
|
|
82a297f395 | ||
|
|
e33062fe0a | ||
|
|
36a1a3cd2e | ||
|
|
70317caeb5 | ||
|
|
f9a6f8524d | ||
|
|
4a4492d034 | ||
|
|
bb9dbb52d0 | ||
|
|
568714d727 | ||
|
|
c87f4102d7 | ||
|
|
3890b6f182 | ||
|
|
5115e217c2 | ||
|
|
904b36e5f7 | ||
|
|
80d319099f | ||
|
|
ee5257d744 | ||
|
|
fa1ab5ab8f | ||
|
|
47300f2ca2 | ||
|
|
27f84ff1b1 | ||
|
|
ffab2cea7a | ||
|
|
614dd140a1 | ||
|
|
0a6b5e78ef | ||
|
|
901c91d10c |
Generated
+4
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.10.0"
|
||||
version = "0.22.1"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
@@ -1418,6 +1418,9 @@ dependencies = [
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"webbrowser",
|
||||
"wgpu",
|
||||
"winit",
|
||||
|
||||
+7
-2
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.10.0"
|
||||
version = "0.22.1"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
@@ -51,6 +51,11 @@ hayro-svg = "0.8"
|
||||
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||
ab_glyph = "0.2"
|
||||
|
||||
# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs)
|
||||
wayland-client = "0.31"
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
|
||||
|
||||
@@ -1,286 +1,231 @@
|
||||
# 🖋️ ink-notes
|
||||
<p align="center">
|
||||
<img src="assets/ink-notes-banner.svg" alt="ink-notes Banner" width="100%">
|
||||
</p>
|
||||
|
||||
Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung für Linux, entwickelt in **Rust** mit **wgpu** und **winit**.
|
||||
|
||||
`ink-notes` kombiniert minimale Eingabelatenz beim Zeichnen mit Stift, Touchpad oder Maus mit einem flüssigen stufenlosen Viewport, automatischer Geometrie- und Formerkennung, Objekttransformationen, Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
|
||||
<p align="center">
|
||||
<strong>Eine moderne Notizbuch-App für Linux zum Schreiben mit Stift, Touch und Maus</strong><br>
|
||||
Geschrieben in <strong>Rust</strong> mit <strong>wgpu</strong>, <strong>winit</strong> und <strong>egui</strong>.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features im Überblick
|
||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
||||
|
||||
### ⚡ Latenzoptimierte Inking-Pipeline
|
||||
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
|
||||
- **Zwei-Stufen-Modell:**
|
||||
1. *Live-Phase:* Eingehende Stylus-Samples werden in Echtzeit durch einen 1€-Filter (One-Euro-Filter) entprellt und inkrementell als GPU-Meshes dargestellt.
|
||||
2. *Commit-Phase (Post-Stroke Finalisierung):* Akausale Ecken-Erkennung (Corner Preservation bei Knicken > 65°), gewichtetes 3-Punkt-Druckfenster, Ramer-Douglas-Peucker (RDP) Dezimierung und saubere runde Endkappen.
|
||||
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen.
|
||||
- **Werkzeuge:**
|
||||
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
||||
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
||||
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
|
||||
- **Text (`T`):** Ins Blatt tippen und mit der Tastatur schreiben – für Überschriften, Code-Schnipsel oder kopierten Text. Normale Schrift oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8 bis 36 pt, Farben der Stiftpalette; Zeilen werden am rechten Rand des Textfelds umbrochen. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren; im PDF-Export ist der Text echt (durchsuchbar und kopierbar). Aus anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||
|
||||
### 🔍 Stufenloser Zoom & Pan (Kamera)
|
||||
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
||||
- **Durchgehendes Scrollen:** Seiten hängen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehängt. Abschaltbar im ☰-Menü unter „Ansicht“ (dann Seite für Seite blättern).
|
||||
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 1000 %** (importierte PDF-Seiten werden beim Hineinzoomen schärfer nachgerendert) zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
|
||||
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
|
||||
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
|
||||
|
||||
### 📐 Geometrie- & Objekterkennung (Shape Recognition)
|
||||
- **Erkannte Primitiven:**
|
||||
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92.
|
||||
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP).
|
||||
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit.
|
||||
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit Richtungspfeilen.
|
||||
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
|
||||
- **Interaktionsmodelle:**
|
||||
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
|
||||
- **Formen-Werkzeug (Shape Mode):** Direkte Erkennung und Ersetzung beim Loslassen.
|
||||
|
||||
### 🖐️ Objektauswahl & Transformation (Lasso / Select)
|
||||
- **Lasso & Klick-Auswahl:** Schnelles Markieren einzelner Striche oder Gruppen per Rahmen (`V`).
|
||||
- **Echtzeit-Verschieben:** Freies Verschieben ausgewählter Objekte über die Seite.
|
||||
- **Stufenlose Skalierung:** Größenänderung über interaktive Eck-Griffe.
|
||||
- **Drehung:** Stufenlose Rotation um das Objektzentrum über den oberen Dreh-Griff oder exakte 90°-Schritte mit Taste `R`.
|
||||
- **Multicolor-Umfärbung:** Ausgewählte Objekte direkt über die Farbpalette umfärben.
|
||||
- **Clipboard & Editieren:** Kopieren (`Strg + C`), Einfügen (`Strg + V`) und Löschen (`Entf` / `Backspace`).
|
||||
- **Undo / Redo:** Vollständige Transaktionshistorie (`Strg + Z` / `Strg + Y`) für alle Aktionen inklusive Transformationen.
|
||||
|
||||
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung
|
||||
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert.
|
||||
- **Dokumenten-Manager:** Toolbar-Modal zum Anlegen, Wechseln, Umbenennen und Löschen von Notizbüchern.
|
||||
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab.
|
||||
|
||||
### 📄 Seiten & Hintergründe
|
||||
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
|
||||
- **Seitenformat aus dem PDF:** Beim PDF-Import bleiben Hochformat-Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren Streifen darunter. Eingefügte Seiten übernehmen das Format der aktuellen Seite.
|
||||
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
|
||||
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anhängen oder direkt nach der aktuellen Seite einfügen (☰-Menü oder Seitenauswahl, z. B. zwischen zwei PDF-Folien), Seitensprung-Menü.
|
||||
|
||||
### 💾 Autosave & Persistenz
|
||||
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
||||
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen.
|
||||
- **Dediziertes Speichern (`Strg + S`):** Sofortiger, synchroner Disk-Flush mit visueller Toast-Rückmeldung.
|
||||
- **Sicherer Shutdown:** `Strg + Q` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
|
||||
|
||||
### 🖨️ Vektor-PDF-Export
|
||||
- Exportiert das Dokument via `printpdf` nach `~/Documents/<Titel>.pdf` (mit automatischer Kollisionsvermeidung `_1`, `_2`).
|
||||
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben. Kürzel: `Strg + E`.
|
||||
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Release, Packaging & Updater
|
||||
## Funktionen
|
||||
|
||||
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
|
||||
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
|
||||
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
|
||||
### Schreiben und Zeichnen
|
||||
- **Vier Stifte** in der Werkzeugleiste, jeder mit eigener Farbe und Stärke (0,15–5 mm, mit Druckstärke). Ändert man Farbe oder Stärke, merkt sich der gewählte Stift das – auch über einen Neustart hinweg. **Textmarker** (halbtransparent, 4–8 mm) in vier Farben; innerhalb eines Strichs wird er nicht dunkler, erst ein neuer Strich deckt stärker.
|
||||
- **Eigene Farben:** Jede Farbe in der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – gewählte Farbe erneut antippen, eine Vorgabe oder mit 🎨 einen freien Farbton wählen. Die Leiste merkt sich die Farben. Am Bildschirm erscheinen sie genauso wie im PDF- und Bildexport.
|
||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Die Stiftrückseite radiert ebenfalls.
|
||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
|
||||
|
||||
### 1. Release-Pakete bauen
|
||||
### Formerkennung
|
||||
- **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
|
||||
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
|
||||
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
|
||||
|
||||
### Auswählen und Bearbeiten
|
||||
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
|
||||
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
|
||||
- `Strg + C` legt die Auswahl zusätzlich als Bild in die Zwischenablage, z. B. für Chat oder Mail.
|
||||
- **Rückgängig/Wiederholen** (`Strg + Z/Y`) für alle Änderungen, je Seite.
|
||||
|
||||
### Text, Bilder und PDFs
|
||||
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
||||
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
||||
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
||||
|
||||
### Seiten und Ansicht
|
||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||
- **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
|
||||
- **Durchgehendes Scrollen:** Seiten hängen lückenlos untereinander, eine dunkle Linie markiert die Grenze; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern, mit Pfeilen in der Werkzeugleiste).
|
||||
- **Seiten einfügen und löschen:** ➕ in der Werkzeugleiste fügt nach der aktuellen Seite eine leere Seite ein. Löschen im ☰-Menü (mit Rückfrage); die zuletzt gelöschte Seite lässt sich dort wiederherstellen, solange die App läuft.
|
||||
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
|
||||
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
|
||||
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
|
||||
|
||||
### Notizbücher und Speichern
|
||||
- Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
|
||||
- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher wie Ordner (samt Inhalt) mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner.
|
||||
- **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
|
||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
||||
|
||||
### Export
|
||||
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
||||
- **Einzelne Seite** als PNG (mit oder ohne Hintergrund) oder SVG über das ☰-Menü.
|
||||
|
||||
### Eingabegeräte
|
||||
- Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste).
|
||||
- Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert.
|
||||
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten.
|
||||
|
||||
### Updates
|
||||
- Die App prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch. Installiert wird nur, wenn die Minisign-Signatur und die SHA-256-Prüfsumme stimmen (Details unten).
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb` und `.AppImage` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
|
||||
|
||||
```bash
|
||||
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/
|
||||
./scripts/build-release.sh
|
||||
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
|
||||
sudo apt install ./ink-notes_<version>_amd64.deb
|
||||
|
||||
# oder AppImage
|
||||
chmod +x ink-notes_<version>_x86_64.AppImage
|
||||
./ink-notes_<version>_x86_64.AppImage
|
||||
```
|
||||
|
||||
Ergebnis in `target/`:
|
||||
- `ink-notes_0.1.0_amd64.deb`
|
||||
- `ink-notes_0.1.0_x86_64.AppImage`
|
||||
- `SHA256SUMS.txt`
|
||||
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
|
||||
Hinweise:
|
||||
- Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
|
||||
- Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
|
||||
|
||||
---
|
||||
|
||||
## Tastenkürzel
|
||||
|
||||
| Aktion | Taste / Geste |
|
||||
| :--- | :--- |
|
||||
| Zeichnen | Stiftspitze, linke Maustaste (Finger, wenn eingeschaltet) |
|
||||
| Radieren | `E` umschalten, rechte Maustaste, Stiftrückseite oder Seitentaste |
|
||||
| Stift in Schwarz / Blau / Rot | `B` / `U` / `R` |
|
||||
| Formen-Werkzeug | `S` oder `F` |
|
||||
| Auswahlwerkzeug | `V` |
|
||||
| Text-Werkzeug | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
|
||||
| Lineal ein/aus | `L` |
|
||||
| Hintergrund wechseln | `G` |
|
||||
| Auswahl um 90° drehen | `R` (bei Auswahl) |
|
||||
| Kopieren / Einfügen | `Strg + C` / `Strg + V` |
|
||||
| Auswahl löschen | `Entf` oder `Rücktaste` |
|
||||
| Rückgängig / Wiederholen | `Strg + Z` / `Strg + Y` oder `Strg + Umschalt + Z` |
|
||||
| Bild einfügen | `Strg + I` |
|
||||
| Scrollen | Mausrad (`Umschalt` = waagerecht), ein Finger |
|
||||
| Zoomen | `Strg + Mausrad`, zwei Finger, `Strg + +` / `Strg + -` |
|
||||
| Zoom zurücksetzen | `Strg + 0` |
|
||||
| Verschieben | mittlere Maustaste oder `Leertaste + Ziehen` |
|
||||
| Nächste / vorherige Seite | `Bild ab` oder `N` / `Bild auf` |
|
||||
| Erste / letzte Seite | `Pos 1` / `Ende` |
|
||||
| Jetzt speichern | `Strg + S` |
|
||||
| PDF exportieren | `Strg + E` |
|
||||
| Menüs schließen, Auswahl aufheben | `Esc` |
|
||||
| Beenden (speichert vorher) | `Strg + Q` oder Fenster schließen |
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Voraussetzungen
|
||||
- Rust 1.92 oder neuer (`rustup update stable`)
|
||||
- Vulkan- oder OpenGL-Treiber (z. B. `mesa-vulkan-drivers`)
|
||||
- X11-/Wayland-Bibliotheken: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`
|
||||
|
||||
### Bauen, starten, testen
|
||||
|
||||
```bash
|
||||
git clone https://gitea.pansi.eu/harald/ink-notes.git
|
||||
cd ink-notes
|
||||
cargo run --release # oder ./run.sh
|
||||
cargo test # Unit- und Integrationstests
|
||||
```
|
||||
|
||||
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`.
|
||||
|
||||
### Projektstruktur
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
||||
├── lib.rs # Bibliotheks-Exporte (für Tests)
|
||||
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
|
||||
├── shader.wgsl # Vertex- und Fragment-Shader
|
||||
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
|
||||
├── filter.rs # 1€-Filter gegen Zittern
|
||||
├── spline.rs # Catmull-Rom-Splines, Ecken-Erhaltung, Dreiecksstreifen
|
||||
├── geometry.rs # Formerkennung (Douglas-Peucker, Kreis-Fit, …)
|
||||
├── selection.rs # Auswahl, Griffe, Verschieben/Drehen/Skalieren
|
||||
├── ruler.rs # Virtuelles Lineal
|
||||
├── text.rs # Textfelder: Satz, Zeilenumbruch, Rasterung
|
||||
├── background.rs # Papierfarben, Musterfarben, Karo/Linien/Punkte
|
||||
├── image_layer.rs # Texturen für Bilder, PDF-Seiten und Text
|
||||
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
||||
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
||||
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||
├── history.rs # Rückgängig/Wiederholen
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||
├── library.rs # Ordner der Notizbücher (library.json)
|
||||
├── autosave.rs # Speichern im Hintergrund
|
||||
├── export.rs # PDF-Export (printpdf)
|
||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||
├── file_picker.rs # Eingebaute Dateiauswahl
|
||||
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json)
|
||||
├── platform.rs # Wayland/X11-Erkennung
|
||||
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung)
|
||||
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge
|
||||
```
|
||||
|
||||
Mehr dazu in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), das Speicherformat steht in [`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md).
|
||||
|
||||
---
|
||||
|
||||
## Releases bauen und veröffentlichen
|
||||
|
||||
```bash
|
||||
./scripts/build-release.sh # .deb, .AppImage und SHA256SUMS.txt in target/
|
||||
```
|
||||
|
||||
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
|
||||
|
||||
### Release-Signatur einrichten (einmalig)
|
||||
|
||||
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
|
||||
Updates werden in der App nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Schlüsselpaar ohne Passwort erzeugen (es wird nur als CI-Secret verwendet):
|
||||
|
||||
```bash
|
||||
rsign generate -W -p ink-notes.pub -s ink-notes.key
|
||||
```
|
||||
|
||||
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
|
||||
1. Die zweite Zeile von `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen.
|
||||
2. Den Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` hinterlegen.
|
||||
3. `ink-notes.key` offline aufbewahren. Geht er verloren, können bestehende Installationen keine Updates mehr prüfen.
|
||||
|
||||
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
|
||||
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
|
||||
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
|
||||
`rsign generate -W` verschlüsselt den Schlüssel mit leerem Passwort; das `minisign`-Programm (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht lesen, deshalb signieren Skript und CI mit `rsign`. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen passt.
|
||||
|
||||
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
|
||||
### CI-Runner (act_runner)
|
||||
|
||||
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
|
||||
Der Workflow läuft auf dem Label `ubuntu-latest`. Im `config.yaml` des Runners muss es zugeordnet sein, z. B.:
|
||||
|
||||
```bash
|
||||
# Natives Debian-Paket installieren
|
||||
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
|
||||
|
||||
# Oder portables AppImage starten
|
||||
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
|
||||
./target/ink-notes_0.1.0_x86_64.AppImage
|
||||
```
|
||||
|
||||
#### Hinweise zu AppImage, glibc & FUSE:
|
||||
- **glibc-Mindestversion:** `ink-notes` benötigt `glibc >= 2.39` (Standard unter Ubuntu 24.04 LTS und Zorin OS 18).
|
||||
- **Host-Grafikstack:** Das AppImage bündelt die Anwendungslogik, nutzt für maximale Performance und Hardwarebeschleunigung jedoch den Treiber- und Grafikstack des Host-Systems (Vulkan ICD-Treiber, Wayland bzw. X11).
|
||||
- **FUSE 2 / FUSE 3 Kompatibilität:** Ubuntu 24.04-basierte Distributionen nutzen standardmäßig FUSE 3 (`fuse3`). Sollte das AppImage beim Start einen Fehler bezüglich `libfuse.so.2` ausgeben, kann entweder das Kompatibilitätspaket installiert werden:
|
||||
```bash
|
||||
sudo apt install libfuse2t64
|
||||
```
|
||||
oder das AppImage direkt ohne FUSE entpackt und ausgeführt werden:
|
||||
```bash
|
||||
./ink-notes_0.1.0_x86_64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### 3. Release auf Gitea veröffentlichen
|
||||
|
||||
Das Skript arbeitet vollkommen idempotent und aktualisiert bei wiederholter Ausführung Release-Notizen und Assets:
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
|
||||
./scripts/publish-gitea-release.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Updater-Sicherheit
|
||||
|
||||
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
||||
|
||||
1. **Minisign-Signatur (Pflicht):**
|
||||
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
|
||||
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
|
||||
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
|
||||
2. **SHA-256-Integritätsprüfung:**
|
||||
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
|
||||
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
|
||||
3. **Redirect-Schutz & HTTPS:**
|
||||
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
|
||||
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
|
||||
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
|
||||
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
|
||||
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
|
||||
5. **Atomarer AppImage-Tausch:**
|
||||
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
|
||||
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
|
||||
|
||||
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
|
||||
|
||||
### Runner-Label konfigurieren
|
||||
Der Workflow nutzt standardmäßig das Runner-Label:
|
||||
```yaml
|
||||
runs-on: ubuntu-latest
|
||||
```
|
||||
|
||||
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
|
||||
```yaml
|
||||
runner:
|
||||
labels:
|
||||
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||
# oder für native Host-Ausführung:
|
||||
# - "ubuntu-latest:host"
|
||||
```
|
||||
|
||||
### Diagnose & Fehlerbehebung
|
||||
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
|
||||
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
|
||||
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
|
||||
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
|
||||
Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstellungen → Aktionen → Runner*) und stimmt das Label mit `runs-on` überein? `sudo` verwendet der Workflow nur, wenn der Runner nicht als `root` läuft.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Schnellstart (Entwicklung)
|
||||
## Sicherheit der Updates
|
||||
|
||||
### Voraussetzungen
|
||||
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`)
|
||||
- **System-Bibliotheken (Linux):**
|
||||
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber)
|
||||
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
|
||||
|
||||
### Bauen & Ausführen
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://gitea.pansi.eu/harald/ink-notes.git
|
||||
cd ink-notes
|
||||
|
||||
# Kompilieren und starten via Starter-Skript
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Alternativ direkt mit Cargo:
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
1. **Signatur:** Jedes Release enthält `SHA256SUMS.txt.minisig`. Die App lädt `SHA256SUMS.txt` und die Signatur vor dem Paket und prüft sie gegen den eingebauten öffentlichen Schlüssel (`minisign-verify`). Ohne gültige Signatur wird nicht installiert.
|
||||
2. **Prüfsumme:** Der SHA-256-Wert des heruntergeladenen Pakets muss zum Eintrag in der signierten Liste passen, sonst wird die Datei gelöscht.
|
||||
3. **Nur HTTPS:** Auch Download-Links und Weiterleitungen müssen HTTPS sein. Bei Weiterleitungen auf einen anderen Server wird der `Authorization`-Header nicht mitgeschickt.
|
||||
4. **Eigener Download-Ordner:** `~/.cache/ink-notes/updates` (Rechte `0700`); Dateinamen mit Pfadtrennern, `..` oder Steuerzeichen werden abgelehnt.
|
||||
5. **AppImage-Austausch:** Die neue Datei wird neben der alten angelegt, die alte als `.bak` gesichert und dann per `rename` ersetzt – die laufende Datei wird nie überschrieben.
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ Tastatur- & Maus-Shortcuts
|
||||
## Lizenz
|
||||
|
||||
| Aktion | Tastenkürzel / Mausgeste |
|
||||
| :--- | :--- |
|
||||
| **Zeichnen** | Linke Maustaste / Stiftspitze |
|
||||
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
|
||||
| **Scrollen (durchgehende Ansicht)** | Mausrad (`Umschalt` = waagerecht) / ein Finger |
|
||||
| **Zoom zentriert auf Cursor** | `Strg + Mausrad` / Touchpad-Pinch (Einzelseiten-Ansicht: Mausrad) |
|
||||
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
|
||||
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
|
||||
| **Auswahlwerkzeug (Lasso)** | `V` |
|
||||
| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
|
||||
| **Auswahl um 90° drehen** | `R` |
|
||||
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
|
||||
| **Auswahl löschen** | `Entf` oder `Backspace` |
|
||||
| **Undo / Redo** | `Strg + Z` / `Strg + Y` (oder `Strg + Shift + Z`) |
|
||||
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
|
||||
| **Vorherige Seite** | `Bild auf (PageUp)` |
|
||||
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
|
||||
| **Jetzt speichern (Disk Flush)** | `Strg + S` |
|
||||
| **Vektor-PDF-Export** | `Strg + E` |
|
||||
| **Dialoge / Menüs schließen** | `Esc` |
|
||||
| **Beenden (mit Disk-Flush)** | `Strg + Q` (oder Fenster schließen) |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Projekt- und Modul-Architektur
|
||||
|
||||
```text
|
||||
src/
|
||||
├── main.rs # Event-Loop (winit), Input-Routing, App-Orchestrierung
|
||||
├── lib.rs # Bibliotheks-Exporte für Tests und Komponenten
|
||||
├── canvas.rs # wgpu Render-Pipeline, GPU-Buffer, Zeichenzustand
|
||||
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
|
||||
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
|
||||
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
|
||||
├── spline.rs # Catmull-Rom, Corner Preservation & Dreiecksstreifen
|
||||
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
|
||||
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
|
||||
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
|
||||
├── document.rs # Notizbuch-Datenmodell, Seiten, DIN-A4-Koordinatensystem
|
||||
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
|
||||
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
|
||||
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
|
||||
├── updater.rs # In-App Gitea Updater (SemVer, HTTPS, SHA256-Integrität, Self-Update)
|
||||
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
|
||||
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
|
||||
```
|
||||
|
||||
Ausführliche Details zur Architektur und mathematischen Modellen findest du unter [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests ausführen
|
||||
|
||||
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
|
||||
[MIT](LICENSE), Copyright © 2026 Harald Pansi.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360">
|
||||
<defs>
|
||||
<!-- Haupt-Hintergrund-Verlauf -->
|
||||
<linearGradient id="bannerBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0f1420"/>
|
||||
<stop offset="40%" stop-color="#141a29"/>
|
||||
<stop offset="100%" stop-color="#090b12"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Subtiles Ambient Glow -->
|
||||
<radialGradient id="ambientGlow" cx="15%" cy="50%" r="45%">
|
||||
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.22"/>
|
||||
<stop offset="70%" stop-color="#1d4ed8" stop-opacity="0.05"/>
|
||||
<stop offset="100%" stop-color="#1d4ed8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="ambientGlowRight" cx="85%" cy="30%" r="40%">
|
||||
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.10"/>
|
||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<!-- Icon Hintergrund-Verlauf -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e2638"/>
|
||||
<stop offset="50%" stop-color="#141824"/>
|
||||
<stop offset="100%" stop-color="#0c0e17"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Stiftkörper-Verlauf -->
|
||||
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Goldene Feder-Spitze -->
|
||||
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fbbf24"/>
|
||||
<stop offset="50%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#d97706"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Tintenspur-Verlauf -->
|
||||
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.95"/>
|
||||
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.85"/>
|
||||
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Text Ink Gradient -->
|
||||
<linearGradient id="textInkGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#60a5fa"/>
|
||||
<stop offset="100%" stop-color="#38bdf8"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Leucht-Effekt -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
<filter id="iconShadow" x="-10%" y="-10%" width="120%" height="130%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
|
||||
<!-- Gitter-Muster für Hintergrund -->
|
||||
<pattern id="gridPattern" width="30" height="30" patternUnits="userSpaceOnUse">
|
||||
<path d="M 30 0 L 0 0 0 30" fill="none" stroke="#94a3b8" stroke-width="0.8" opacity="0.07"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Banner-Rahmen & Hintergrund -->
|
||||
<rect width="1200" height="360" rx="20" fill="url(#bannerBg)" stroke="#27324b" stroke-width="2"/>
|
||||
|
||||
<!-- Hintergrund-Gitter (Notizbuch-Raster) -->
|
||||
<rect width="1200" height="360" rx="20" fill="url(#gridPattern)"/>
|
||||
|
||||
<!-- Subtile Glows -->
|
||||
<rect width="1200" height="360" rx="20" fill="url(#ambientGlow)"/>
|
||||
<rect width="1200" height="360" rx="20" fill="url(#ambientGlowRight)"/>
|
||||
|
||||
<!-- ==================== LINKER BEREICH: APP-ICON ==================== -->
|
||||
<g transform="translate(65, 55) scale(0.488)" filter="url(#iconShadow)">
|
||||
<!-- App-Icon Hintergrund (Squircle) -->
|
||||
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
|
||||
|
||||
<!-- DIN-A4 Dokumenten-Gitter im Icon -->
|
||||
<g opacity="0.14" stroke="#94a3b8" stroke-width="2">
|
||||
<line x1="80" y1="160" x2="432" y2="160" />
|
||||
<line x1="80" y1="220" x2="432" y2="220" />
|
||||
<line x1="80" y1="280" x2="432" y2="280" />
|
||||
<line x1="80" y1="340" x2="432" y2="340" />
|
||||
<line x1="80" y1="400" x2="432" y2="400" />
|
||||
|
||||
<line x1="140" y1="100" x2="140" y2="430" />
|
||||
<line x1="200" y1="100" x2="200" y2="430" />
|
||||
<line x1="260" y1="100" x2="260" y2="430" />
|
||||
<line x1="320" y1="100" x2="320" y2="430" />
|
||||
<line x1="380" y1="100" x2="380" y2="430" />
|
||||
</g>
|
||||
|
||||
<!-- Tintenspur -->
|
||||
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
|
||||
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
|
||||
|
||||
<!-- Stylus / Füllfederhalter -->
|
||||
<g transform="translate(240, 110) rotate(45)">
|
||||
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
|
||||
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
|
||||
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
|
||||
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="0" cy="65" r="4" fill="#78350f"/>
|
||||
</g>
|
||||
|
||||
<!-- Glanzpunkt -->
|
||||
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
|
||||
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
|
||||
</g>
|
||||
|
||||
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||
<g transform="translate(360, 0)">
|
||||
<!-- Version & Platform Pill -->
|
||||
<g transform="translate(0, 68)">
|
||||
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
||||
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
||||
v0.14.0 • LINUX • RUST & WGPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Haupttitel: ink-notes -->
|
||||
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
||||
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Subtitle / Tagline -->
|
||||
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
||||
Notizbuch- & PDF-Annotations-App für Stift, Touch und Maus
|
||||
</text>
|
||||
|
||||
<!-- Feature Pills / Highlights -->
|
||||
<g transform="translate(0, 248)">
|
||||
<!-- Pill 1: Niedrige Latenz -->
|
||||
<g transform="translate(0, 0)">
|
||||
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
⚡ Schnelles wgpu-Inking
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 2: Stylus mit Druck -->
|
||||
<g transform="translate(198, 0)">
|
||||
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🖊️ Druck & Palm Rejection
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 3: Formerkennung -->
|
||||
<g transform="translate(400, 0)">
|
||||
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
📐 Formenerkennung & Lineal
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Pill 4: 100% lokal -->
|
||||
<g transform="translate(594, 0)">
|
||||
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
||||
🔒 100% lokal & atomar
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
+159
-117
@@ -1,153 +1,195 @@
|
||||
# Architektur & Technische Dokumentation von ink-notes
|
||||
# Architektur von ink-notes
|
||||
|
||||
Dieses Dokument beschreibt die interne Architektur, mathematische Modelle und Rendering-Pipelines von `ink-notes`.
|
||||
Dieses Dokument beschreibt, wie ink-notes intern aufgebaut ist: Eingabe und Strichverarbeitung, Rendering, Seiten und Ansicht, Formerkennung, Speichern und Export. Das Speicherformat steht in [`FILE_FORMAT.md`](FILE_FORMAT.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Die Inking-Pipeline
|
||||
## 1. Überblick
|
||||
|
||||
ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thread. Dazu kommen drei Hintergrund-Threads:
|
||||
|
||||
| Thread | Aufgabe |
|
||||
| :--- | :--- |
|
||||
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
|
||||
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
|
||||
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
|
||||
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
|
||||
|
||||
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vom Stift zum Strich
|
||||
|
||||
```
|
||||
[Hardware Stylus / Touch / Maus]
|
||||
│
|
||||
▼
|
||||
[winit Event-Loop]
|
||||
│ (tx, ty, pressure, timestamp)
|
||||
▼
|
||||
[Viewport Invertierung] (screen_to_page: Umrechnung in DIN-A4-Einheiten)
|
||||
│
|
||||
▼
|
||||
[1€-Filter] (Jitter-Beseitigung bei langsamen Bewegungen)
|
||||
│
|
||||
▼
|
||||
[Centripetal Catmull-Rom] (C1-stetige Spline-Interpolation, alpha = 0.5)
|
||||
│
|
||||
▼
|
||||
[Mesh-Tessellierung] (Drucksensitive Dreiecksstreifen via Normalen)
|
||||
│
|
||||
▼
|
||||
[wgpu GPU-Buffer] (Vertex- & Index-Buffer im VRAM)
|
||||
[Stift / Touch / Maus]
|
||||
│ Bildschirmposition, Druck, Zeit
|
||||
▼
|
||||
[Bildschirm → Seitenkoordinaten] (viewport.rs)
|
||||
│
|
||||
▼
|
||||
[Druckkurve] (settings.rs, vom Nutzer einstellbar)
|
||||
│
|
||||
▼
|
||||
[1€-Filter] (filter.rs)
|
||||
│
|
||||
▼
|
||||
[Catmull-Rom-Spline + Dreiecksstreifen] (spline.rs) → Live-Puffer auf der GPU
|
||||
│ Stift abgehoben
|
||||
▼
|
||||
[Nachbearbeitung] (spline.rs: finalize_stroke)
|
||||
│
|
||||
▼
|
||||
[Strich auf der Seite + Undo-Eintrag] (document.rs, history.rs)
|
||||
```
|
||||
|
||||
### 1.1. 1€-Filter (One-Euro-Filter)
|
||||
Stift- und Touch-Eingaben unterliegen bei niedrigen Geschwindigkeiten oft Mikrozittern (Jitter). Standard-Tiefpassfilter verursachen jedoch bei schnellen Schwüngen eine störende Verzögerung (Latenz).
|
||||
### 2.1 1€-Filter
|
||||
Langsame Bewegungen zittern, schnelle sollen nicht nachhinken. Der 1€-Filter (`filter.rs`) passt seine Grenzfrequenz an die Geschwindigkeit an:
|
||||
|
||||
Der 1€-Filter (`filter.rs`) löst dieses Dilemma mit einer adaptiven Grenzfrequenz $f_c$:
|
||||
$$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$
|
||||
- Bei geringer Geschwindigkeit ($|\dot{x}| \approx 0$) dominiert $f_{c,\min}$, wodurch Zittern maximal geglättet wird.
|
||||
- Bei schnellen Zügen wächst $\beta \cdot |\dot{x}|$ an, wodurch der Filter durchlässig wird und Latenz eliminiert.
|
||||
|
||||
### 1.2. Centripetal Catmull-Rom Splines
|
||||
Um Ecken zu vermeiden und eine organische Schriftführung zu erzielen, werden Abtastpunkte mit einem zentripetalen Catmull-Rom-Spline (`spline.rs`) interpoliert ($\alpha = 0.5$):
|
||||
$$t_{i+1} = t_i + ||P_{i+1} - P_i||^\alpha$$
|
||||
Diese Parametrisierung garantiert:
|
||||
- Keine Selbstüberschneidungen bei scharfen Wendungen.
|
||||
- Keine unerwünschten Cusp-Schleifen.
|
||||
- Perfekte $C^1$-Stetigkeit an den Stützstellen.
|
||||
Bei langsamer Bewegung glättet er stark, bei schneller kaum.
|
||||
|
||||
### 1.3. Drucksensitive Dreiecksstreifen (Tessellierung)
|
||||
Aus den interpolierten Spline-Punkten mit Radius $r_i = f(\text{pressure}_i)$ wird ein Triangle-Strip erzeugt:
|
||||
1. Richtungsvektor $T_i = P_{i+1} - P_i$.
|
||||
2. Senkrechter Normalenvektor $N_i = (-T_{i,y}, T_{i,x}) / ||T_i||$.
|
||||
3. An inneren Stützstellen werden Normalen benachbarter Segmente gemittelt (Winkelhalbierende).
|
||||
4. Eckpunkte des Dreiecksstreifens:
|
||||
$$V_{i,\text{left}} = P_i + N_i \cdot r_i, \quad V_{i,\text{right}} = P_i - N_i \cdot r_i$$
|
||||
### 2.2 Zentripetaler Catmull-Rom-Spline
|
||||
Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert:
|
||||
|
||||
### 1.4. Zwei-Stufen-Modell (Live-Glättung vs. akausale Post-Stroke-Finalisierung)
|
||||
Um minimale Latenz beim Zeichnen mit maximaler Präzision und Ecken-Erhaltung zu verbinden, arbeitet `ink-notes` mit einem strikten Zwei-Stufen-Modell:
|
||||
$$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$
|
||||
|
||||
1. **Live-Phase (Kausal, Zero-Latency):**
|
||||
- Eingehende Punkte durchlaufen in Echtzeit den 1€-Filter (`filter.rs`).
|
||||
- `spline.rs` tesselliert inkrementell temporäre Triangle-Strips für die unverzögerte Bildschirmdarstellung im GPU-Live-Buffer.
|
||||
2. **Commit-Phase (Akausal, `finalize_stroke`):**
|
||||
- Beim Absetzen des Stifts wird der gesamte Strich akausal analysiert:
|
||||
- **Druckglättung:** Glättung des Druckverlaufs $p$ über ein gewichtetes 3-Punkt-Fenster $[0.25, 0.5, 0.25]$ zur Ausblendung von Digitalisierer-Stufen.
|
||||
- **RDP-Dezimierung:** Ausdünnung kollinearer Zwischenpunkte mit Ramer-Douglas-Peucker ($\epsilon \approx 0.075\text{ mm} = 0.75\text{ Einheiten}$).
|
||||
- **Ecken-Erkennung (Corner Preservation):** Bei Richtungsänderungen $> 65^\circ$ wird die $C^1$-Spline-Kontinuität zu $C^0$ aufgebrochen, sodass Ecken (z. B. 90°-L-Knicke oder Zickzack-Linien) gestochen scharf bleiben und nicht rundgeschliffen werden.
|
||||
- **Runde Kappen & Miter-Joins:** Generierung sauberer runder Kappen an Start und Ende des Triangle-Strips.
|
||||
- Der temporäre Live-Buffer wird vollständig entleert und der bereinigte `Stroke` in den persistenten Puffer der Seite überführt.
|
||||
Diese Parametrisierung erzeugt bei engen Kurven keine Schlaufen oder Spitzen und ist an den Stützstellen $C^1$-stetig.
|
||||
|
||||
### 2.3 Dreiecksstreifen
|
||||
Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen:
|
||||
|
||||
$$V_{i,\text{links}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{rechts}} = P_i - N_i \cdot r_i$$
|
||||
|
||||
$N_i$ ist die Normale der Laufrichtung; an inneren Punkten wird über benachbarte Segmente gemittelt.
|
||||
|
||||
### 2.4 Zwei Stufen
|
||||
1. **Während des Schreibens:** Filter und Spline arbeiten nur mit bisherigen Punkten; der Strich wird sofort in einen eigenen Live-Puffer geschrieben.
|
||||
2. **Beim Absetzen** wird der ganze Strich noch einmal bearbeitet (`finalize_stroke`):
|
||||
- Druckverlauf mit dem Fenster $[0{,}25;\ 0{,}5;\ 0{,}25]$ glätten,
|
||||
- Punkte mit Ramer-Douglas-Peucker ausdünnen ($\varepsilon = 0{,}75$ Einheiten $= 0{,}075$ mm),
|
||||
- an Knicken über 65° den Spline auftrennen, damit Ecken nicht abgerundet werden,
|
||||
- runde Endkappen anfügen.
|
||||
|
||||
Danach wird der Live-Puffer geleert und der Strich in die Seite übernommen.
|
||||
|
||||
### 2.5 Lineal
|
||||
Beginnt ein Strich nahe einer Linealkante (`ruler.rs`), werden alle seine Punkte auf diese Kante projiziert, bis der Stift abgehoben wird.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rendering & Kamera-Pipeline (`wgpu`)
|
||||
## 3. Koordinaten, Seiten und Ansicht
|
||||
|
||||
### 2.1. Virtuelles Koordinatensystem
|
||||
Die Anwendung rechnet intern in virtuellen DIN-A4-Koordinaten:
|
||||
- Breite: **2100 Einheiten** (210 mm)
|
||||
- Höhe: **2970 Einheiten** (297 mm)
|
||||
- 1 mm entspricht exakt **10 Einheiten**.
|
||||
### 3.1 Seitenkoordinaten
|
||||
Gerechnet wird in Seiteneinheiten: **10 Einheiten = 1 mm**. Eine DIN-A4-Seite ist 2100 × 2970 Einheiten groß. Jede Seite hat ihre eigene Größe (`Page::size`): Standard ist A4 hoch; beim PDF-Import bekommen Querformat-Seiten 2970 Einheiten Breite und eine Höhe nach Seitenverhältnis (`document::pdf_page_size`). Neue Seiten übernehmen die Größe der aktuellen.
|
||||
|
||||
### 2.2. Viewport & Projektionsmatrix (`viewport.rs`)
|
||||
Der Vertex-Shader transformiert DIN-A4-Koordinaten über eine Orthogonal-Projektion kombiniert mit Pan und Zoom in Normalized Device Coordinates (NDC, $[-1, 1]$):
|
||||
### 3.2 Maßstab und Kamera
|
||||
- `PageViewport` (`document.rs`) passt einen Rahmen, in den alle Seiten des Notizbuchs passen (`Notebook::fit_box`), ins Fenster ein. Daraus ergibt sich ein Maßstab (Bildschirmpixel je Einheit), der für alle Seiten gilt. Die aktuelle Seite liegt waagerecht zentriert und oben im Rahmen. Den Platz der Werkzeugleiste rechnet der Viewport heraus.
|
||||
- `ViewportState` (`viewport.rs`) ergänzt Zoom (25 % bis 1000 %) und Verschiebung (Pan). Beim Zoomen bleibt der Punkt unter dem Mauszeiger stehen:
|
||||
|
||||
```wgsl
|
||||
struct CameraUniform {
|
||||
transform: mat4x4<f32>,
|
||||
};
|
||||
```
|
||||
|
||||
Die Projektionsmatrix $M$ wird berechnet aus:
|
||||
1. Skalierung des DIN-A4-Papiers auf den Viewport unter Berücksichtigung des Seitenverhältnisses.
|
||||
2. Anwenden des Zoom-Faktors $z \in [0.25, 5.0]$.
|
||||
3. Translation durch den Pan-Offset $(p_x, p_y)$.
|
||||
|
||||
### 2.3. Cursor-zentrierter Zoom
|
||||
Beim Zoomen mit dem Mausrad wird der neue Pan-Offset so korrigiert, dass der Punkt unter dem Cursor fixiert bleibt:
|
||||
$$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$
|
||||
|
||||
### 2.4. Render-Passes
|
||||
In jedem Frame werden zwei Hauptpässe durchlaufen:
|
||||
1. **Pass 1 (Canvas):**
|
||||
- Hintergrund-Quad und Linien/Gitter (Instanced oder Batch Vertex-Buffer).
|
||||
- Gespeicherte Striche und Geometrie-Meshes.
|
||||
- Aktiver Live-Strich (Ephemeral Buffer).
|
||||
- Auswahl-Overlay & Transformationsgriffe.
|
||||
2. **Pass 2 (egui UI):**
|
||||
- Floating Glassmorphism-Toolbar.
|
||||
- Farb- und Werkzeug-Popovers.
|
||||
- Modal-Dokumentenmanager.
|
||||
- Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
|
||||
|
||||
### 3.3 Durchgehende Ansicht
|
||||
Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten lückenlos darunter bzw. darüber (die Grenze zeichnet die Oberfläche als dunkle Linie), waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
|
||||
|
||||
Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
|
||||
|
||||
---
|
||||
|
||||
## 3. Geometrieerkennung & Shape-Fitting (`geometry.rs`)
|
||||
## 4. Rendering
|
||||
|
||||
Die Erkennung erfolgt rein analytisch und deterministisch ohne schwere ML-Frameworks:
|
||||
Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
|
||||
|
||||
### 3.1. Ramer-Douglas-Peucker (RDP)
|
||||
Reduziert den Rohpfad auf signifikante Eckpunkte mit konfigurierbarem Toleranzabstand $\epsilon$.
|
||||
1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
|
||||
2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
|
||||
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Seitengrenzen, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
|
||||
|
||||
### 3.2. Erkennungsregeln:
|
||||
- **Gerade:** Verhältnis von Sehnenlänge ($||P_{\text{end}} - P_{\text{start}}||$) zur Pfad-Bogenlänge $> 0.92$.
|
||||
- **Rechteck:** RDP liefert 4 dominante Ecken; Kantenwinkel liegen zwischen $75^\circ$ und $105^\circ$.
|
||||
- **Kreis / Ellipse:** Pfad ist geschlossen ($||P_{\text{start}} - P_{\text{end}}|| < 0.1 \times \text{Umfang}$); Standardabweichung der Radien vom Mittelpunkt ist minimal ($< 0.12 \times \bar{r}$).
|
||||
- **Koordinatensystem:** Zwei orthogonale Schenkel mit Schnittpunkt nahe dem Start/Ende; Winkel $80^\circ$ bis $100^\circ$ wird exakt auf $90^\circ$ eingerastet; positive Achsenenden erhalten Pfeilspitzen.
|
||||
- **Dreiecke / Rhomben / Oktaeder:** Spezifische Eckpunkt- und Symmetrieanalysen der RDP-Kontur.
|
||||
Jeder Strich ist ein eigener Draw-Call mit eigener Nummer im Stencil-Puffer: Ein Pixel, das der Strich schon gefärbt hat, wird kein zweites Mal gefärbt. So bleibt halbtransparente Tinte (Textmarker) gleichmäßig, auch wo sich der Strich selbst überlappt (Anfangskappe, Kehren, langsames Hin- und Herfahren); erst ein neuer Strich deckt stärker. Nach 255 Strichen setzt ein bildschirmfüllendes Dreieck den Stencil-Puffer zurück.
|
||||
|
||||
### 3.3. Draw-and-Hold Erkennung
|
||||
Hält der Anwender den Stift am Ende eines Strichs für $> 400\text{ ms}$ innerhalb eines Radius von $< 3\text{ px}$ ruhig, wird die Form im fliegenden Wechsel als Vektorform visualisiert und beim Abheben des Stifts fixiert.
|
||||
### 4.1 Bildebene
|
||||
`image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
|
||||
- **PDF-Seiten** rendert ein Hintergrund-Thread mit `hayro` (längste Kante 2560 Pixel, beim starken Hineinzoomen 5120 Pixel). Bis die schärfere Fassung fertig ist, bleibt die vorhandene sichtbar.
|
||||
- **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
|
||||
|
||||
### 4.2 Papier- und Musterfarben
|
||||
Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`; sie sind linear angegeben und werden im Shader nach sRGB umgerechnet. Tintenfarben (Striche, Text) sind sRGB-Werte – dieselben Zahlen landen im PDF und in den Farbpunkten der Oberfläche. Gezeichnet wird ohne sRGB-Umrechnung der GPU (`render_format`), Texturen sind `Rgba8Unorm`: So wird halbtransparente Tinte wie im PDF in sRGB gemischt und sieht am Bildschirm genauso aus wie im Export. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
|
||||
|
||||
---
|
||||
|
||||
## 4. Persistenz & Autosave-Architektur (`storage.rs`, `autosave.rs`)
|
||||
## 5. Objekte auf der Seite
|
||||
|
||||
Alles auf einer Seite ist ein `Stroke` (`document.rs`):
|
||||
- **Tinte:** Abtastpunkte, Werkzeug (Stift, Textmarker, Form), Breite, Farbe; die Dreiecke werden beim Laden aus den Punkten neu berechnet.
|
||||
- **Formen:** zusätzlich eine `RecognizedShape` (siehe Abschnitt 6).
|
||||
- **Bilder und Textfelder:** vier Eckpunkte (oben links, oben rechts, unten rechts, unten links) plus Verweis auf die Bilddatei bzw. ein `TextBox`. Weil die Ecken normale Punkte sind, wirken Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle.
|
||||
|
||||
### 5.1 Textfelder
|
||||
`text.rs` setzt den Text selbst: Zeilenumbruch an Leerzeichen (zu lange Wörter zeichenweise), Unterschneidung, Tabulator = 4 Leerzeichen. Bildschirm-Textur, PDF-Export und Bearbeitung verwenden dieselben Schriften (Ubuntu Light und Hack aus den eingebauten `egui`-Schriften) und denselben Umbruch. Bearbeitet wird in einem `egui`-Textfeld an der Stelle des Felds; danach wird die Höhe des Felds an den Text angepasst, Lage, Drehung und Skalierung bleiben.
|
||||
|
||||
### 5.2 Rückgängig
|
||||
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
|
||||
|
||||
**Platz schaffen** (`Notebook::make_space`) betrifft mehrere Seiten: Die Seite, auf der angesetzt wurde, und die folgenden bis vor die nächste PDF-Seite gelten als durchgehende Spalte. Alles unterhalb der Ansatzhöhe rutscht um denselben Betrag; ein Objekt, das über ein Seitenende ragen würde, beginnt 10 mm unter dem Anfang der nächsten Seite, und alles danach rutscht entsprechend weiter. Fehlende Seiten werden eingefügt. Der Schritt (`SpaceShift`: jeder verschobene Strich vorher/nachher mit Seite) landet im Verlauf der Seite, auf der angesetzt wurde; `Notebook::undo`/`redo` buchen ihn über alle Seiten zurück (später geänderte Striche bleiben unberührt) und entfernen dafür angelegte, leer gebliebene Seiten wieder.
|
||||
|
||||
---
|
||||
|
||||
## 6. Formerkennung
|
||||
|
||||
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Zuerst wird auf ein Achsenkreuz geprüft (alle Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, beide reichen auf beiden Seiten über den Schnittpunkt hinaus). Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
|
||||
|
||||
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
|
||||
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
|
||||
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
|
||||
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
|
||||
- **Achsenkreuz:** ein „+“ in einem Zug; die Achsen werden waagerecht und senkrecht ausgerichtet und bekommen an allen vier Enden Pfeilspitzen.
|
||||
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
|
||||
|
||||
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
|
||||
|
||||
---
|
||||
|
||||
## 7. Speichern
|
||||
|
||||
```
|
||||
[UI / Inking Event]
|
||||
│
|
||||
▼
|
||||
[mark_page_dirty]
|
||||
│
|
||||
▼
|
||||
[AutosaveController] ──(1.5s Debounce)──► [Hintergrund-Thread (MPSC)]
|
||||
│
|
||||
▼
|
||||
[Temp-Datei *.tmp schreiben]
|
||||
│
|
||||
▼
|
||||
[Atomares std::fs::rename]
|
||||
│
|
||||
▼
|
||||
[manifest.json / page_<id>.json]
|
||||
[Änderung]
|
||||
│ mark_page_dirty / mark_manifest_dirty
|
||||
▼
|
||||
[AutosaveController] ── 1,5 s nach der letzten Änderung, spätestens nach 10 s ──► [Autosave-Thread]
|
||||
│
|
||||
▼
|
||||
[temporäre Datei schreiben + fsync]
|
||||
│
|
||||
▼
|
||||
[rename über die alte Datei, Ordner-fsync]
|
||||
```
|
||||
|
||||
- **Keine UI-Blockierung:** Disk-I/O erfolgt vollständig in einem dedizierten Worker-Thread.
|
||||
- **Atomarität:** Daten werden stets zuerst in `.json.tmp` geschrieben und via `fs::rename` atomar ersetzt.
|
||||
- **Verlustfreier Notizbuchwechsel:** Bei `switch_notebook_dir` wird synchron `flush()` aufgerufen, um alle ungespeicherten Daten des aktuellen Dokuments sicherzustellen, bevor der Pfad gewechselt wird.
|
||||
- Der Haupt-Thread schreibt nie selbst; er übergibt fertige Seitendaten an den Autosave-Thread.
|
||||
- Schlägt ein Schreibvorgang fehl, wird die Seite mit wachsendem Abstand (1 s bis 30 s) erneut versucht; der Status wird rot.
|
||||
- `flush()` schreibt alles Ausstehende und wartet darauf. Es läuft bei `Strg + S`, beim Wechsel des Notizbuchs und beim Beenden. Schlägt es beim Beenden fehl, bleibt die App offen und bietet Wiederholen oder einen Notfall-Export der ungespeicherten Seiten an (`~/.local/share/ink-notes/recovery/`).
|
||||
- Seiten werden erst beim Anzeigen geladen (Lazy Loading); Titel, Hintergrund und Größe stehen im Manifest, damit Seitenübersicht und durchgehende Ansicht sie vorher kennen.
|
||||
- Ein zweites Fenster mit demselben Notizbuch öffnet es schreibgeschützt (Sperrdatei).
|
||||
|
||||
---
|
||||
|
||||
## 8. Export
|
||||
|
||||
`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen:
|
||||
|
||||
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier).
|
||||
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
||||
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
||||
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt.
|
||||
|
||||
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||
|
||||
PNG und SVG einer Seite (`page_export.rs`) entstehen aus diesem PDF: `hayro` rastert es bzw. wandelt es in SVG um. So sehen alle Exporte gleich aus.
|
||||
|
||||
---
|
||||
|
||||
## 9. Updates
|
||||
|
||||
`updater.rs` fragt die Gitea-Releases ab, vergleicht die Version (SemVer) und lädt auf Wunsch das passende Paket. Vor der Installation prüft er die Minisign-Signatur von `SHA256SUMS.txt` gegen den eingebauten Schlüssel und danach die SHA-256-Prüfsumme des Pakets. Ein AppImage wird per `rename` ersetzt; ein `.deb`-Paket wird mit `xdg-open` an die Softwareinstallation des Systems übergeben. Details im README unter „Sicherheit der Updates“.
|
||||
|
||||
+156
-101
@@ -1,137 +1,192 @@
|
||||
# Dateiformat & Speicherstruktur von ink-notes
|
||||
# Dateiformat von ink-notes
|
||||
|
||||
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
||||
Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 8**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Verzeichnisstruktur
|
||||
|
||||
Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
|
||||
## 1. Ordner
|
||||
|
||||
```text
|
||||
~/.local/share/ink-notes/
|
||||
└── notebooks/
|
||||
├── <notebook_id_1>/
|
||||
│ ├── manifest.json
|
||||
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
|
||||
│ ├── page_1.json
|
||||
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
|
||||
│ ├── page_2.json
|
||||
│ └── ...
|
||||
└── <notebook_id_2>/
|
||||
├── manifest.json
|
||||
└── page_1.json
|
||||
~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
|
||||
├── notebooks/
|
||||
│ ├── library.json Ordner der Notizbücher (siehe unten)
|
||||
│ ├── <notizbuch-id>/
|
||||
│ │ ├── manifest.json Metadaten und Seitenreihenfolge
|
||||
│ │ ├── manifest.json.bak Kopie des vorherigen Manifests
|
||||
│ │ ├── page_1.json eine Datei je Seite
|
||||
│ │ ├── page_2.json
|
||||
│ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen)
|
||||
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
|
||||
│ │ │ ├── <sha256>.png
|
||||
│ │ │ └── <sha256>.pdf
|
||||
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
|
||||
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
|
||||
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
|
||||
|
||||
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
|
||||
```
|
||||
|
||||
---
|
||||
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
|
||||
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
||||
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
||||
|
||||
## 2. Dokumenten-Manifest (`manifest.json`)
|
||||
|
||||
Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge und IDs aller Seiten:
|
||||
### Ordner (`library.json`)
|
||||
Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben (samt Inhalt) und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei.
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 2,
|
||||
"id": "notebook-a1b2c3d4",
|
||||
"title": "Mathematik Vorlesung",
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"background": "Grid5mm"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"background": "DotGrid"
|
||||
}
|
||||
],
|
||||
"page_ids": [1, 2],
|
||||
"current_page_index": 0,
|
||||
"next_page_id": 3,
|
||||
"next_stroke_id": 42,
|
||||
"created_at": 1775460000,
|
||||
"modified_at": 1775463600
|
||||
"folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"],
|
||||
"notebooks": { "analysis_1775460000": "3. Semester/Mathe" }
|
||||
}
|
||||
```
|
||||
|
||||
### Felder:
|
||||
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
|
||||
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
||||
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
||||
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
|
||||
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
|
||||
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
||||
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
||||
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
|
||||
- `created_at` / `modified_at`: UNIX-Zeitstempel in Sekunden.
|
||||
- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen.
|
||||
- Notizbücher ohne Eintrag liegen auf der obersten Ebene.
|
||||
- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben.
|
||||
- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Seitendatei (`page_<id>.json`)
|
||||
|
||||
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
|
||||
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
|
||||
## 2. Manifest (`manifest.json`)
|
||||
|
||||
```json
|
||||
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
|
||||
{
|
||||
"format_version": 8,
|
||||
"id": "vorlesung_1775460000",
|
||||
"title": "Vorlesung",
|
||||
"created_at": 1775460000,
|
||||
"modified_at": 1775463600,
|
||||
"current_page_index": 0,
|
||||
"pages": [
|
||||
{ "id": 1, "background": "Blank", "title": "Einführung", "size": [2970.0, 1671.0] },
|
||||
{ "id": 2, "background": "Grid5mm" }
|
||||
],
|
||||
"page_ids": [1, 2],
|
||||
"next_stroke_id": 42,
|
||||
"next_page_id": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Felder:
|
||||
- `format_version`: Format-Versionsnummer (`2`).
|
||||
- `id`: Eindeutige ID der Seite.
|
||||
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
||||
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
||||
- `id`: Eindeutige ID des Strichs.
|
||||
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
|
||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
|
||||
- `shape`: Optionales Enum für geometrische Vektorformen:
|
||||
- `Line { start, end }`
|
||||
- `Rectangle { top_left, width, height }`
|
||||
- `Circle { center, radius }`
|
||||
- `Axes { origin, x_end, y_end, has_arrows }`
|
||||
- `Triangle { p1, p2, p3 }`
|
||||
- `Rhombus { center, width, height }`
|
||||
- `Octahedron { center, radius }`
|
||||
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
|
||||
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
|
||||
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
|
||||
| Feld | Bedeutung |
|
||||
| :--- | :--- |
|
||||
| `format_version` | Formatversion (siehe Abschnitt 5) |
|
||||
| `id`, `title` | ID (= Ordnername) und Anzeigename |
|
||||
| `created_at`, `modified_at` | UNIX-Zeit in Sekunden |
|
||||
| `current_page_index` | zuletzt geöffnete Seite |
|
||||
| `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
|
||||
| `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
|
||||
| `next_stroke_id`, `next_page_id` | nächste freie IDs |
|
||||
|
||||
---
|
||||
|
||||
## 4. Datensicherheit & Migration
|
||||
## 3. Seite (`page_<id>.json`)
|
||||
|
||||
### Atomares Schreiben & Verzeichnis-Sync
|
||||
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
|
||||
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
|
||||
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
|
||||
Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umbrochen):
|
||||
|
||||
### Quarantäne bei Parsefehlern
|
||||
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
|
||||
```json
|
||||
{
|
||||
"format_version": 8,
|
||||
"id": 1,
|
||||
"background": "Blank",
|
||||
"title": "Einführung",
|
||||
"size": [2970.0, 1671.0],
|
||||
"pdf_background": { "asset": "<sha256>.pdf", "page": 0, "width_pt": 960.0, "height_pt": 540.0 },
|
||||
"strokes": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
### Migrations-Backups & Rolling Backups
|
||||
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
|
||||
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
|
||||
| Feld | Bedeutung |
|
||||
| :--- | :--- |
|
||||
| `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
|
||||
| `title` | Seitentitel (optional) |
|
||||
| `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`) |
|
||||
| `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
|
||||
| `strokes` | alle Objekte der Seite, von unten nach oben |
|
||||
|
||||
### 3.1 Objekte (`strokes`)
|
||||
|
||||
Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `samples`: Punkte mit `x`, `y` (Seiteneinheiten), `p` (Druck 0–1) und `t` (Zeit in Sekunden). Die Dreiecke für die Anzeige werden beim Laden aus den Punkten berechnet und nicht gespeichert.
|
||||
|
||||
**Tinte**
|
||||
|
||||
```json
|
||||
{ "id": 7, "color": [0.12, 0.12, 0.14, 1.0],
|
||||
"samples": [{ "x": 350.2, "y": 420.5, "p": 0.65, "t": 12.345 }, …],
|
||||
"tool": "pen", "width": 5.0, "finalized": true }
|
||||
```
|
||||
|
||||
- `tool`: `pen`, `highlighter` oder `shape`
|
||||
- `width`: Strichbreite in Seiteneinheiten (5.0 = 0,5 mm)
|
||||
- `finalized`: Der Strich wurde nach dem Absetzen nachbearbeitet (Glättung, Ecken, Endkappen)
|
||||
|
||||
**Formen** haben zusätzlich `shape`, z. B.:
|
||||
|
||||
```json
|
||||
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
|
||||
```
|
||||
|
||||
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows, negative_ends? }`, `Octahedron { … }`. Punkte sind `[x, y]`. Beim Achsenkreuz enthält `negative_ends` die Enden der x- und der y-Achse auf der anderen Seite des Ursprungs; ältere Versionen ignorieren das Feld und zeigen ein L-förmiges Koordinatensystem.
|
||||
|
||||
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
|
||||
|
||||
```json
|
||||
{ "id": 9, "color": [1, 1, 1, 1], "samples": [ …4 Ecken… ], "width": 0.0, "image": "<sha256>.png" }
|
||||
```
|
||||
|
||||
**Textfelder** haben `text`; die vier `samples` sind wie bei Bildern die Ecken des Felds:
|
||||
|
||||
```json
|
||||
"text": { "content": "Überschrift\nzweite Zeile", "size": 42.3, "font": "Sans", "wrap_width": 1500.0 }
|
||||
```
|
||||
|
||||
- `size`: Schriftgröße (Em) in Seiteneinheiten (12 pt ≈ 42,3)
|
||||
- `font`: `Sans` (Ubuntu Light) oder `Mono` (Hack)
|
||||
- `wrap_width`: Umbruchbreite in Seiteneinheiten; die Ecken bilden das Feld dieser Breite (und der Höhe des gesetzten Texts) ab, Drehung und Skalierung stecken in den Ecken
|
||||
|
||||
---
|
||||
|
||||
## 5. Versionshistorie
|
||||
## 4. Sicherheit beim Speichern
|
||||
|
||||
| Version | Status | Beschreibung |
|
||||
|---|---|---|
|
||||
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
|
||||
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
|
||||
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
|
||||
### Atomares Schreiben
|
||||
1. Der Inhalt wird in `<datei>.tmp.<pid>.<nanosekunden>` geschrieben und mit `fsync` auf den Datenträger gebracht.
|
||||
2. `rename` ersetzt die alte Datei in einem Schritt.
|
||||
3. Danach wird auch der Ordner mit `fsync` gesichert.
|
||||
|
||||
Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder die neue Datei, nie eine halbe.
|
||||
|
||||
### Beschädigte Dateien
|
||||
- Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
|
||||
- Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
|
||||
|
||||
### Gelöschte Seiten
|
||||
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
|
||||
|
||||
### Neuere Versionen
|
||||
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
||||
|
||||
### Migration
|
||||
Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine Kopie `*.v0.bak` angelegt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
|
||||
## 5. Versionen
|
||||
|
||||
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
|
||||
- **Vorteile**:
|
||||
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
|
||||
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
|
||||
- **Nachteile**:
|
||||
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
|
||||
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
|
||||
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
|
||||
- **Entscheidung**:
|
||||
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
|
||||
| Version | App | Änderung |
|
||||
| :--- | :--- | :--- |
|
||||
| 0 | – | ohne `format_version` |
|
||||
| 1 | – | `format_version`, Hintergründe im Manifest |
|
||||
| 2 | – | `tool`, `width`, `finalized` an Strichen; kompaktes JSON |
|
||||
| 3 | 0.3.7 | Formen `Ellipse` und `Arrow` |
|
||||
| 4 | 0.5 | Bilder (`image`, Ordner `assets/`) |
|
||||
| 5 | 0.6.1 | importierte PDF-Seiten (`pdf_background`) |
|
||||
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
|
||||
| 7 | 0.10 | Textfelder (`text`) |
|
||||
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
|
||||
|
||||
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
|
||||
|
||||
---
|
||||
|
||||
## 6. Warum JSON
|
||||
|
||||
Ein Binärformat (z. B. `postcard`) würde Dateien um weitere 20–30 % verkleinern. JSON bleibt trotzdem, weil es sich ansehen, vergleichen und im Notfall von Hand reparieren lässt und das Speichern einer Seite auch bei vielen Strichen deutlich unter einer Millisekunde dauert.
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
Version=1.0
|
||||
Name=ink-notes
|
||||
GenericName=Notizbuch- & Inking-App
|
||||
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
||||
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||
Exec=ink-notes %F
|
||||
Icon=eu.pansi.ink-notes
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Graphics;Utility;
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;
|
||||
StartupWMClass=ink-notes
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
|
||||
StartupWMClass=eu.pansi.ink-notes
|
||||
MimeType=application/x-inknotes;
|
||||
Actions=NewWindow;
|
||||
|
||||
|
||||
@@ -4,20 +4,22 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>ink-notes</name>
|
||||
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
|
||||
<summary>Notizbuch-App für Stift, Touch und Maus</summary>
|
||||
<description>
|
||||
<p>
|
||||
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux,
|
||||
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus.
|
||||
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
|
||||
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
|
||||
</p>
|
||||
<p>Hauptmerkmale:</p>
|
||||
<p>Funktionen:</p>
|
||||
<ul>
|
||||
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
|
||||
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
|
||||
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
|
||||
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
|
||||
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
|
||||
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
|
||||
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
|
||||
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
|
||||
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
|
||||
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
Version=1.0
|
||||
Name=ink-notes
|
||||
GenericName=Notizbuch- & Inking-App
|
||||
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
||||
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||
Exec=ink-notes %F
|
||||
Icon=eu.pansi.ink-notes
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Graphics;Utility;
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;
|
||||
StartupWMClass=ink-notes
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
|
||||
StartupWMClass=eu.pansi.ink-notes
|
||||
MimeType=application/x-inknotes;
|
||||
Actions=NewWindow;
|
||||
|
||||
|
||||
@@ -4,20 +4,22 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>ink-notes</name>
|
||||
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
|
||||
<summary>Notizbuch-App für Stift, Touch und Maus</summary>
|
||||
<description>
|
||||
<p>
|
||||
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux,
|
||||
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus.
|
||||
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
|
||||
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
|
||||
</p>
|
||||
<p>Hauptmerkmale:</p>
|
||||
<p>Funktionen:</p>
|
||||
<ul>
|
||||
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
|
||||
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
|
||||
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
|
||||
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
|
||||
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
|
||||
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
|
||||
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
|
||||
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
|
||||
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
|
||||
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
## ink-notes {{TAG_NAME}}
|
||||
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **App-Symbol**: Das Fenster meldet sich als eu.pansi.ink-notes – Dock und Taskleiste zeigen das ink-notes-Symbol statt eines allgemeinen Symbols (unter X11 auch als Fenstersymbol)
|
||||
- **Platz schaffen**: Neues Werkzeug ↕ – aufs Blatt tippen und nach unten ziehen, alles darunter rutscht mit, auch auf den folgenden Seiten (über das Seitenende hinaus geht es auf der nächsten Seite weiter, fehlende Seiten werden angehängt); nach oben ziehen schließt leeren Platz; ein Schritt für Rückgängig
|
||||
- **Achsenkreuz**: Ein „+“ in einem Zug (mit Halten oder dem Formen-Werkzeug) wird zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; auch im PDF-/Bildexport
|
||||
- **Fehlerbehebung Stift**: Das erste Antippen mit dem Stift trifft jetzt – Knöpfe und Notizbücher reagieren sofort statt erst beim zweiten Tippen; Striche beginnen nicht mehr mit einer Linie von der vorherigen Stiftposition
|
||||
- **Tasten am Stift belegen**: Im ☰-Menü unter Eingabe jede Stifttaste mit Radieren, Auswählen, Textmarker, Stift oder Nichts belegen (gilt, solange die Taste gedrückt ist); Tasten erscheinen nach dem ersten Drücken und leuchten beim Drücken grün
|
||||
- **Ordner verschieben**: Ordner lassen sich jetzt wie Notizbücher mit 📂 samt Inhalt in andere Ordner oder auf die oberste Ebene verschieben
|
||||
- **Ordner für Notizbücher**: Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager anlegen, umbenennen und löschen, Notizbücher mit 📂 verschieben; neue Notizbücher landen im geöffneten Ordner. Die Notizbuch-Dateien bleiben unverändert (Zuordnung in library.json)
|
||||
- **Einfügen per Finger**: Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle (wie in Rnote)
|
||||
- **Eigene Farben**: Jede Farbe der Werkzeugleiste (Stifte, Textmarker, Text) lässt sich anpassen – erneut antippen, Vorgabe oder mit 🎨 frei wählen; die Leiste merkt sich die Farben. Farben am Bildschirm wie im PDF-/Bildexport (Schwarz ist schwarz, Textmarker mischt wie im PDF); Karos und Linien im PDF so hell wie am Bildschirm
|
||||
- **Beta-Feedback II**: Vier Stifte mit eigener Farbe und Stärke, die sich Änderungen merken; Textmarker wird innerhalb eines Strichs nicht dunkler und ist jetzt auch im PDF-/PNG-/SVG-Export durchsichtig; Seiten lückenlos untereinander mit Trennlinie; neue Seite per ➕ in der Werkzeugleiste; Seite löschen im ☰-Menü (wiederherstellbar); Koordinatensystem ohne Loch am Ursprung
|
||||
- **Fehlerbehebungen**: Umbenennen und Löschen in der Notizbuchverwaltung reagieren wieder; Karos, Linien und Punkte werden pro Pixel berechnet und liegen in jedem Zoom gleichmäßig (mindestens ein Pixel breit, kantengeglättet)
|
||||
- **Strichqualität**: Kleine Schleifen wie in „o“, „a“ oder „e“ bleiben rund (der fertige Strich entsteht aus den ungefilterten Stiftpunkten); Strichkanten werden kantengeglättet (4× Multisampling), runde Strichenden sind feiner unterteilt
|
||||
- **Musterfarbe**: Karos, Linien und Punkte in hell, mittel, kräftig, blau oder grün (☰-Menü, gilt auch im Export); Punkte des Punktrasters am Bildschirm so groß wie im Export (0,4 mm). README und Dokumentation überarbeitet
|
||||
- **Beta-Feedback**: Querformat-PDFs (z. B. PowerPoint-Folien) bekommen Querformat-Seiten ohne leeren Streifen (Dateiformat-Version 8); neue Seiten direkt nach der aktuellen einfügen; Auswahlwerkzeug funktioniert mit dem Stift; Zoom bis 1000 % mit schärfer nachgerenderten PDF-Seiten
|
||||
- **Text-Werkzeug**: Mit T ins Blatt tippen und schreiben – normale oder Code-Schrift, 8–36 pt, Zeilenumbruch am Feldrand; Textfelder verschieben, drehen und skalieren wie Bilder; im PDF echter, durchsuchbarer Text; kopierter Text wird mit Strg+V zum Textfeld (Dateiformat-Version 7)
|
||||
- **Durchgehendes Scrollen**: Seiten untereinander mit Mausrad, Finger oder Touchpad scrollen (Strg+Mausrad zoomt); Schreiben im unteren Fünftel der letzten Seite hängt automatisch eine neue an; abschaltbar unter ☰ → Ansicht
|
||||
- **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug
|
||||
@@ -13,7 +27,7 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
|
||||
- **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
|
||||
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Vorschau, zum Anpassen an den eigenen Stift
|
||||
- **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`
|
||||
@@ -21,21 +35,21 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
|
||||
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
|
||||
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Stifteingabe über das Tablet-Protokoll von Wayland bzw. X11, mit Druckstärke
|
||||
- **Touchscreen Multi-Touch**: Zoomen mit zwei Fingern, Verschieben mit einem; Zeichnen mit dem Finger zuschaltbar
|
||||
- **Zwei-Stufen-Inking-Modell**: Glättung während des Schreibens (1€-Filter), beim Absetzen Nachbearbeitung mit Ecken-Erhaltung
|
||||
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
|
||||
- **Mehrere Notizbücher**: Anlegen, umbenennen, löschen (mit Papierkorb); automatisches Speichern im Hintergrund
|
||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, beschädigte Seiten werden beiseitegelegt statt überschrieben, Schutz vor gleichzeitigem Öffnen, atomares Speichern
|
||||
- **In-App Updater**: Prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch – nur mit gültiger Minisign-Signatur und über HTTPS
|
||||
- **PDF-Export**: Striche und Formen als Vektoren, für Druck und Weitergabe
|
||||
|
||||
### Installation:
|
||||
- **Zorin OS / Ubuntu / Debian (.deb)**:
|
||||
```bash
|
||||
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
|
||||
```
|
||||
- **Portables AppImage (Alle Linux-Distributionen)**:
|
||||
- **AppImage (andere Distributionen mit glibc ≥ 2.39)**:
|
||||
```bash
|
||||
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
|
||||
./ink-notes_{{VERSION}}_x86_64.AppImage
|
||||
|
||||
@@ -104,9 +104,7 @@ cat <<EOF > "${CHANGELOG_TMP}"
|
||||
ink-notes (${VERSION}-1) stable; urgency=medium
|
||||
|
||||
* Release v${VERSION}.
|
||||
* Low-latency vector inking with two-stage pipeline.
|
||||
* Shape recognition and infinite canvas with zoom/pan.
|
||||
* Multi-notebook management and PDF export.
|
||||
* See the release notes on https://gitea.pansi.eu/harald/ink-notes/releases
|
||||
|
||||
-- Harald <harald@pansi.eu> $(date -R)
|
||||
EOF
|
||||
@@ -130,10 +128,10 @@ Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-ic
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Homepage: https://gitea.pansi.eu/harald/ink-notes
|
||||
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux
|
||||
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu),
|
||||
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen,
|
||||
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
|
||||
Description: Notizbuch-App für Stift, Touch und Maus
|
||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen und
|
||||
kann PDFs zum Beschriften importieren sowie Bilder und Textfelder einfügen.
|
||||
Mehrere Notizbücher, automatisches Speichern und Export als PDF, PNG oder SVG.
|
||||
EOF
|
||||
chmod 644 "${STAGING_DIR}/DEBIAN/control"
|
||||
|
||||
|
||||
+174
-130
@@ -5,10 +5,63 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::canvas::InkVertex;
|
||||
use crate::document::PageBackground;
|
||||
|
||||
/// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export).
|
||||
/// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PatternColor {
|
||||
#[default]
|
||||
Light,
|
||||
Medium,
|
||||
Strong,
|
||||
Blue,
|
||||
Green,
|
||||
}
|
||||
|
||||
impl PatternColor {
|
||||
pub const ALL: [PatternColor; 5] = [
|
||||
PatternColor::Light,
|
||||
PatternColor::Medium,
|
||||
PatternColor::Strong,
|
||||
PatternColor::Blue,
|
||||
PatternColor::Green,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PatternColor::Light => "Hell",
|
||||
PatternColor::Medium => "Mittel",
|
||||
PatternColor::Strong => "Kräftig",
|
||||
PatternColor::Blue => "Blau",
|
||||
PatternColor::Green => "Grün",
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearer Farbwert des Musters auf dem Papier `paper` (für den Export: weißes Papier).
|
||||
pub fn color(self, paper: PaperTheme) -> [f32; 4] {
|
||||
let light = paper.pattern_color();
|
||||
let dark = paper.is_dark();
|
||||
let scaled = |f: f32| [light[0] * f, light[1] * f, light[2] * f, 1.0];
|
||||
match (self, dark) {
|
||||
(PatternColor::Light, _) => light,
|
||||
// Helles Papier: kräftiger = dunkler
|
||||
(PatternColor::Medium, false) => scaled(0.62),
|
||||
(PatternColor::Strong, false) => scaled(0.28),
|
||||
// Dunkles Papier: kräftiger = heller
|
||||
(PatternColor::Medium, true) => [0.16, 0.16, 0.17, 1.0],
|
||||
(PatternColor::Strong, true) => [0.32, 0.32, 0.34, 1.0],
|
||||
(PatternColor::Blue, false) => [0.35, 0.55, 0.95, 1.0],
|
||||
(PatternColor::Blue, true) => [0.06, 0.14, 0.42, 1.0],
|
||||
(PatternColor::Green, false) => [0.32, 0.72, 0.38, 1.0],
|
||||
(PatternColor::Green, true) => [0.05, 0.22, 0.09, 1.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
|
||||
pub const LINE_THICKNESS: f32 = 1.5;
|
||||
/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm).
|
||||
pub const DOT_SIZE: f32 = 2.5;
|
||||
/// Punktgröße für das Punktraster in Seiteneinheiten (4 = 0,4 mm, wie im PDF-Export).
|
||||
pub const DOT_SIZE: f32 = 4.0;
|
||||
|
||||
/// Rasterabstand 5 mm (50 Einheiten).
|
||||
pub const GRID_5MM_SPACING: f32 = 50.0;
|
||||
@@ -18,7 +71,8 @@ pub const LINED_SPACING: f32 = 75.0;
|
||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||
///
|
||||
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||
/// Papier- und Musterfarben sind linear (der Shader rechnet sie in sRGB um); Tintenfarben
|
||||
/// dagegen sind sRGB-Werte wie im Export.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaperTheme {
|
||||
@@ -85,7 +139,24 @@ impl PaperTheme {
|
||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||
};
|
||||
wgpu::Color { r, g, b, a: 1.0 }
|
||||
// Linear angegeben; gezeichnet wird ohne sRGB-Umrechnung
|
||||
let srgb = |c: f64| f64::from(linear_to_srgb(c as f32));
|
||||
wgpu::Color {
|
||||
r: srgb(r),
|
||||
g: srgb(g),
|
||||
b: srgb(b),
|
||||
a: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linearer Farbanteil (Papier, Muster) als sRGB-Wert (Tinte, Export).
|
||||
pub fn linear_to_srgb(c: f32) -> f32 {
|
||||
let c = c.clamp(0.0, 1.0);
|
||||
if c <= 0.003_130_8 {
|
||||
c * 12.92
|
||||
} else {
|
||||
1.055 * c.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,115 +183,70 @@ pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex>
|
||||
]
|
||||
}
|
||||
|
||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||
#[inline]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
color: [f32; 4],
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
||||
/// Oberer und unterer Rand ohne Linien bei liniertem Papier (15 mm).
|
||||
pub const LINED_MARGIN: f32 = 150.0;
|
||||
|
||||
/// Ecke des Musterrechtecks: Das Muster selbst berechnet der Fragment-Shader (`fs_pattern`)
|
||||
/// für jedes Bildschirmpixel. So liegen Linien und Punkte in jedem Zoom exakt gleichmäßig,
|
||||
/// sind mindestens ein Pixel breit und kantengeglättet – vorher gerasterte Haarlinien
|
||||
/// fielen je nach Lage auf das Pixelraster und wirkten unregelmäßig.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PatternVertex {
|
||||
/// Seitenkoordinate
|
||||
pub position: [f32; 2],
|
||||
pub color: [f32; 4],
|
||||
/// Art (1 = Karo, 2 = Linien, 3 = Punkte), Abstand, Linienstärke, Punktgröße
|
||||
pub params: [f32; 4],
|
||||
/// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien, ungenutzt
|
||||
pub bounds: [f32; 4],
|
||||
}
|
||||
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
|
||||
/// einem Blatt der Größe `size`.
|
||||
pub fn generate_background_mesh(
|
||||
impl PatternVertex {
|
||||
const ATTRIBUTES: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
|
||||
0 => Float32x2,
|
||||
1 => Float32x4,
|
||||
2 => Float32x4,
|
||||
3 => Float32x4
|
||||
];
|
||||
|
||||
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<PatternVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &Self::ATTRIBUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechteck über das ganze Blatt der Größe `size`, auf dem der Shader das Muster
|
||||
/// `background` in `color` zeichnet; leer bei blankem Papier.
|
||||
pub fn generate_pattern_quad(
|
||||
background: PageBackground,
|
||||
color: [f32; 4],
|
||||
size: glam::Vec2,
|
||||
) -> Vec<InkVertex> {
|
||||
let (page_width, page_height) = (size.x, size.y);
|
||||
match background {
|
||||
PageBackground::Blank => Vec::new(),
|
||||
PageBackground::Grid5mm => {
|
||||
let mut vertices = Vec::with_capacity(1200);
|
||||
let half_t = LINE_THICKNESS / 2.0;
|
||||
|
||||
// Vertikale Linien alle 5 mm
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < page_width {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_t,
|
||||
0.0,
|
||||
x + half_t,
|
||||
page_height,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
// Horizontale Linien alle 5 mm
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < page_height {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
page_width,
|
||||
y + half_t,
|
||||
);
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
PageBackground::Lined => {
|
||||
let mut vertices = Vec::with_capacity(600);
|
||||
let half_t = LINE_THICKNESS / 2.0;
|
||||
|
||||
// Oberer und unterer Randabstand (z. B. 15 mm)
|
||||
let margin_top = 150.0;
|
||||
let margin_bottom = 150.0;
|
||||
|
||||
let mut y = margin_top;
|
||||
while y <= page_height - margin_bottom {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
page_width,
|
||||
y + half_t,
|
||||
);
|
||||
y += LINED_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
PageBackground::DotGrid => {
|
||||
let mut vertices = Vec::with_capacity(15000);
|
||||
let half_d = DOT_SIZE / 2.0;
|
||||
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < page_height {
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < page_width {
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_d,
|
||||
y - half_d,
|
||||
x + half_d,
|
||||
y + half_d,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
vertices
|
||||
}
|
||||
}
|
||||
) -> Vec<PatternVertex> {
|
||||
let params = match background {
|
||||
PageBackground::Blank => return Vec::new(),
|
||||
PageBackground::Grid5mm => [1.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
PageBackground::Lined => [2.0, LINED_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
PageBackground::DotGrid => [3.0, GRID_5MM_SPACING, LINE_THICKNESS, DOT_SIZE],
|
||||
};
|
||||
let bounds = [size.x, size.y, LINED_MARGIN, 0.0];
|
||||
let corner = |x: f32, y: f32| PatternVertex {
|
||||
position: [x, y],
|
||||
color,
|
||||
params,
|
||||
bounds,
|
||||
};
|
||||
vec![
|
||||
corner(0.0, 0.0),
|
||||
corner(size.x, 0.0),
|
||||
corner(0.0, size.y),
|
||||
corner(0.0, size.y),
|
||||
corner(size.x, 0.0),
|
||||
corner(size.x, size.y),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -229,6 +255,31 @@ mod tests {
|
||||
|
||||
use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH};
|
||||
|
||||
#[test]
|
||||
fn test_pattern_colors_get_more_visible() {
|
||||
// Luminanz (linear) als Abstand zum Papier
|
||||
let lum = |c: [f32; 4]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
for paper in PaperTheme::ALL {
|
||||
let p = lum(paper.paper_color());
|
||||
let contrast = |c: PatternColor| (lum(c.color(paper)) - p).abs();
|
||||
assert_eq!(PatternColor::Light.color(paper), paper.pattern_color());
|
||||
assert!(
|
||||
contrast(PatternColor::Medium) > contrast(PatternColor::Light),
|
||||
"{paper:?}"
|
||||
);
|
||||
assert!(
|
||||
contrast(PatternColor::Strong) > contrast(PatternColor::Medium),
|
||||
"{paper:?}"
|
||||
);
|
||||
for c in [PatternColor::Blue, PatternColor::Green] {
|
||||
assert!(
|
||||
contrast(c) > contrast(PatternColor::Light),
|
||||
"{c:?} auf {paper:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paper_quad_generation() {
|
||||
let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE);
|
||||
@@ -245,32 +296,25 @@ mod tests {
|
||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
#[test]
|
||||
fn test_background_meshes() {
|
||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
|
||||
assert!(blank.is_empty());
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
|
||||
assert!(!grid.is_empty());
|
||||
assert_eq!(
|
||||
grid.len() % 6,
|
||||
0,
|
||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||
);
|
||||
|
||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
|
||||
assert!(!lined.is_empty());
|
||||
assert_eq!(lined.len() % 6, 0);
|
||||
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
|
||||
assert!(!dots.is_empty());
|
||||
assert_eq!(dots.len() % 6, 0);
|
||||
fn test_pattern_quads() {
|
||||
assert!(generate_pattern_quad(PageBackground::Blank, PATTERN, A4_SIZE).is_empty());
|
||||
for (background, kind) in [
|
||||
(PageBackground::Grid5mm, 1.0),
|
||||
(PageBackground::Lined, 2.0),
|
||||
(PageBackground::DotGrid, 3.0),
|
||||
] {
|
||||
let quad = generate_pattern_quad(background, PATTERN, A4_SIZE);
|
||||
assert_eq!(quad.len(), 6);
|
||||
assert!(quad
|
||||
.iter()
|
||||
.all(|v| v.params[0] == kind && v.color == PATTERN));
|
||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
assert_eq!(quad[0].bounds[..2], [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_mesh_uses_theme_color() {
|
||||
let dark = PaperTheme::Black.pattern_color();
|
||||
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
|
||||
assert!(mesh.iter().all(|v| v.color == dark));
|
||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
||||
assert!(quad
|
||||
.iter()
|
||||
|
||||
+352
-54
@@ -1,7 +1,7 @@
|
||||
//! GPU-Canvas-Renderpipeline mit wgpu und Low-Latency-Verwaltung.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Zero-Latency-Pufferung via `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`.
|
||||
//! - Geringe Bildverzögerung durch `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`.
|
||||
//! - Strikte Pipeline-Trennung: DIN-A4-Hintergrund, permanente Striche und temporäre Live-Geometrie.
|
||||
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
|
||||
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
|
||||
@@ -12,7 +12,9 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
|
||||
use crate::background::{
|
||||
generate_paper_quad, generate_pattern_quad, PaperTheme, PatternColor, PatternVertex,
|
||||
};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -122,21 +124,26 @@ struct NeighborFingerprint {
|
||||
background: PageBackground,
|
||||
size: [u32; 2],
|
||||
paper_theme: PaperTheme,
|
||||
pattern: PatternColor,
|
||||
vertex_count: usize,
|
||||
stroke_count: usize,
|
||||
last_stroke_id: Option<u64>,
|
||||
/// Neuaufbau des Caches (z. B. verschobener Inhalt bei gleicher Strichzahl)
|
||||
cache_generation: u64,
|
||||
}
|
||||
|
||||
impl NeighborFingerprint {
|
||||
fn of(page: &Page, paper_theme: PaperTheme) -> Self {
|
||||
fn of(page: &Page, paper_theme: PaperTheme, pattern: PatternColor) -> Self {
|
||||
Self {
|
||||
page_id: page.id,
|
||||
background: page.background,
|
||||
size: page.size.to_array().map(f32::to_bits),
|
||||
paper_theme,
|
||||
pattern,
|
||||
vertex_count: page.vertex_cache.len(),
|
||||
stroke_count: page.strokes.len(),
|
||||
last_stroke_id: page.strokes.last().map(|s| s.id),
|
||||
cache_generation: page.cache_generation,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,8 +155,9 @@ struct NeighborPage {
|
||||
offset: glam::Vec2,
|
||||
camera_buffer: wgpu::Buffer,
|
||||
camera_bind_group: wgpu::BindGroup,
|
||||
background_buffer: Option<wgpu::Buffer>,
|
||||
background_vertex_count: u32,
|
||||
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
|
||||
paper_buffer: wgpu::Buffer,
|
||||
pattern_buffer: Option<wgpu::Buffer>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
}
|
||||
@@ -158,6 +166,13 @@ pub struct CanvasState {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||
adapter: wgpu::Adapter,
|
||||
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||
msaa_samples: u32,
|
||||
msaa_view: Option<wgpu::TextureView>,
|
||||
/// Stencil-Puffer: Jeder Strich deckt ein Pixel nur einmal, damit sich halbtransparente
|
||||
/// Tinte (Textmarker) dort, wo der Strich sich selbst überlappt, nicht verdunkelt
|
||||
stencil_view: wgpu::TextureView,
|
||||
stencil_reset_pipeline: wgpu::RenderPipeline,
|
||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||
presentation_warned: bool,
|
||||
pub device: wgpu::Device,
|
||||
@@ -182,14 +197,17 @@ pub struct CanvasState {
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
/// Musterrechteck der aktuellen Seite (6 Ecken, leer bei blankem Papier)
|
||||
pub background_buffer: wgpu::Buffer,
|
||||
pub background_vertex_capacity: usize,
|
||||
pub background_vertex_count: u32,
|
||||
pattern_pipeline: wgpu::RenderPipeline,
|
||||
pub current_background: PageBackground,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink: bool,
|
||||
/// Farbe von Karos, Linien und Punkten
|
||||
pattern: PatternColor,
|
||||
/// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen
|
||||
page_size: glam::Vec2,
|
||||
fit_box: glam::Vec2,
|
||||
@@ -206,6 +224,9 @@ pub struct CanvasState {
|
||||
pub live_vertex_capacity: usize,
|
||||
pub live_vertex_count: u32,
|
||||
pub live_samples: Vec<StylusSample>,
|
||||
/// Ungefilterte Eingabepunkte des Live-Strichs (nach dem Lineal): Grundlage des fertigen
|
||||
/// Strichs, damit kleine Schleifen nicht durch die Filterverzögerung schrumpfen
|
||||
live_raw_samples: Vec<StylusSample>,
|
||||
pub live_control_points: Vec<ControlPoint>,
|
||||
pub live_vertices: Vec<InkVertex>,
|
||||
pub filter: OneEuroFilter,
|
||||
@@ -236,7 +257,6 @@ pub struct CanvasState {
|
||||
impl CanvasState {
|
||||
const INITIAL_LIVE_CAPACITY: usize = 4096;
|
||||
const INITIAL_COMMITTED_CAPACITY: usize = 65536;
|
||||
const INITIAL_BACKGROUND_CAPACITY: usize = 16384;
|
||||
pub const VIEWPORT_MARGIN: f32 = 24.0;
|
||||
|
||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||
@@ -299,13 +319,20 @@ impl CanvasState {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
|
||||
// 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1.
|
||||
// Gezeichnet wird ohne sRGB-Umrechnung (siehe `render_format`); bietet die Fläche
|
||||
// nur sRGB-Formate an, wird eine Ansicht ohne sRGB verwendet.
|
||||
let surface_format = surface_caps
|
||||
.formats
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| f.is_srgb())
|
||||
.find(|f| !f.is_srgb())
|
||||
.unwrap_or(surface_caps.formats[0]);
|
||||
let view_formats = if surface_format.is_srgb() {
|
||||
vec![surface_format.remove_srgb_suffix()]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
@@ -314,11 +341,24 @@ impl CanvasState {
|
||||
height: size.height.max(1),
|
||||
present_mode,
|
||||
alpha_mode: surface_caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
view_formats,
|
||||
desired_maximum_frame_latency: 1, // Minimiert Eingabelatenz auf 1 Frame
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
// Kantenglättung der Zeichenfläche (4× Multisampling), falls das Format es erlaubt
|
||||
let msaa_samples = if adapter
|
||||
.get_texture_format_features(render_format(&config))
|
||||
.flags
|
||||
.sample_count_supported(MSAA_SAMPLES)
|
||||
{
|
||||
MSAA_SAMPLES
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let msaa_view = create_msaa_view(&device, &config, msaa_samples);
|
||||
let stencil_view = create_stencil_view(&device, &config, msaa_samples);
|
||||
|
||||
// 5. Shader & Bind Group Layout
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Ink WGSL Shader"),
|
||||
@@ -384,7 +424,7 @@ impl CanvasState {
|
||||
module: &shader,
|
||||
entry_point: "fs_ink",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
format: render_format(&config),
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -398,11 +438,42 @@ impl CanvasState {
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
depth_stencil: Some(stroke_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// Setzt den Stencil-Puffer zurück, wenn die Strichnummern überlaufen
|
||||
let stencil_reset_pipeline =
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Stencil Reset Pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs_fullscreen",
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_none",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: render_format(&config),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(stencil_reset()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// TriangleList-Pipeline (für Hintergrundpapier und Muster)
|
||||
let triangle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Background TriangleList Pipeline"),
|
||||
@@ -416,7 +487,7 @@ impl CanvasState {
|
||||
module: &shader,
|
||||
entry_point: "fs_main",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
format: render_format(&config),
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -430,8 +501,11 @@ impl CanvasState {
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
depth_stencil: Some(passive_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
@@ -447,19 +521,46 @@ impl CanvasState {
|
||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||
|
||||
let current_background = PageBackground::Grid5mm;
|
||||
let bg_mesh = generate_background_mesh(
|
||||
let bg_mesh = generate_pattern_quad(
|
||||
current_background,
|
||||
paper_theme.pattern_color(),
|
||||
crate::document::A4_SIZE,
|
||||
);
|
||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer"),
|
||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||
label: Some("Pattern Quad Buffer"),
|
||||
size: (6 * std::mem::size_of::<PatternVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&background_buffer, 0, bytemuck::cast_slice(&bg_mesh));
|
||||
|
||||
// Karo, Linien und Punkte: pro Pixel im Shader berechnet
|
||||
let pattern_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Pattern Pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs_pattern",
|
||||
buffers: &[PatternVertex::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_pattern",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: render_format(&config),
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(passive_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: msaa_samples,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
|
||||
// 7. GPU-Puffer für Live- und Committed-Geometrie
|
||||
let live_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Live Stroke Vertex Buffer"),
|
||||
@@ -479,15 +580,20 @@ impl CanvasState {
|
||||
&device,
|
||||
&shader,
|
||||
&bind_group_layout,
|
||||
config.format,
|
||||
render_format(&config),
|
||||
msaa_samples,
|
||||
);
|
||||
|
||||
// 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, render_format(&config), None, 1);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
adapter,
|
||||
msaa_samples,
|
||||
msaa_view,
|
||||
stencil_view,
|
||||
stencil_reset_pipeline,
|
||||
presentation_warned: false,
|
||||
device,
|
||||
queue,
|
||||
@@ -504,11 +610,12 @@ impl CanvasState {
|
||||
current_stroke_width_mm: 0.5,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
background_vertex_count: bg_mesh.len() as u32,
|
||||
pattern_pipeline,
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
pattern: PatternColor::default(),
|
||||
page_size: crate::document::A4_SIZE,
|
||||
fit_box: crate::document::A4_SIZE,
|
||||
text_specs: HashMap::new(),
|
||||
@@ -518,6 +625,7 @@ impl CanvasState {
|
||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||
live_vertex_count: 0,
|
||||
live_samples: Vec::with_capacity(256),
|
||||
live_raw_samples: Vec::with_capacity(256),
|
||||
live_control_points: Vec::with_capacity(256),
|
||||
live_vertices: Vec::with_capacity(1024),
|
||||
filter: OneEuroFilter::default(),
|
||||
@@ -634,6 +742,8 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
|
||||
self.recalculate_viewport();
|
||||
}
|
||||
}
|
||||
@@ -785,6 +895,14 @@ impl CanvasState {
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Setzt die Farbe von Karos, Linien und Punkten.
|
||||
pub fn set_pattern_color(&mut self, pattern: PatternColor) {
|
||||
if self.pattern != pattern {
|
||||
self.pattern = pattern;
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||
pub fn set_background(&mut self, background: PageBackground) {
|
||||
if self.current_background == background {
|
||||
@@ -796,23 +914,12 @@ impl CanvasState {
|
||||
|
||||
/// 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(
|
||||
let mesh = generate_pattern_quad(
|
||||
self.current_background,
|
||||
self.paper_theme.pattern_color(),
|
||||
self.pattern.color(self.paper_theme),
|
||||
self.page_size,
|
||||
);
|
||||
self.background_vertex_count = mesh.len() as u32;
|
||||
|
||||
if mesh.len() > self.background_vertex_capacity {
|
||||
self.background_vertex_capacity = mesh.len() * 2;
|
||||
self.background_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer (Reallocated)"),
|
||||
size: (self.background_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !mesh.is_empty() {
|
||||
self.queue
|
||||
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||
@@ -874,7 +981,7 @@ impl CanvasState {
|
||||
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 fingerprint = NeighborFingerprint::of(page, self.paper_theme, self.pattern);
|
||||
let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) {
|
||||
Some(i) => old.swap_remove(i),
|
||||
None => self.upload_neighbor(page, fingerprint),
|
||||
@@ -920,20 +1027,39 @@ impl CanvasState {
|
||||
buffer
|
||||
})
|
||||
};
|
||||
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
|
||||
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
background.extend(generate_background_mesh(
|
||||
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
|
||||
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
let pattern = generate_pattern_quad(
|
||||
page.background,
|
||||
self.paper_theme.pattern_color(),
|
||||
self.pattern.color(self.paper_theme),
|
||||
page.size,
|
||||
));
|
||||
);
|
||||
let paper_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Paper Buffer"),
|
||||
size: std::mem::size_of_val(paper.as_slice()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper));
|
||||
let pattern_buffer = (!pattern.is_empty()).then(|| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Neighbor Pattern Buffer"),
|
||||
size: std::mem::size_of_val(pattern.as_slice()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&buffer, 0, bytemuck::cast_slice(&pattern));
|
||||
buffer
|
||||
});
|
||||
NeighborPage {
|
||||
fingerprint,
|
||||
offset: glam::Vec2::ZERO,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
||||
background_vertex_count: background.len() as u32,
|
||||
paper_buffer,
|
||||
pattern_buffer,
|
||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||
stroke_ranges: page.stroke_ranges.clone(),
|
||||
}
|
||||
@@ -1038,6 +1164,8 @@ impl CanvasState {
|
||||
.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);
|
||||
self.live_raw_samples.clear();
|
||||
self.push_raw_sample(page_x, page_y, pressure, timestamp);
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||
@@ -1063,6 +1191,7 @@ impl CanvasState {
|
||||
|
||||
// 1. Zwingend durch den 1€-Filter leiten
|
||||
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||
self.push_raw_sample(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);
|
||||
|
||||
@@ -1085,6 +1214,25 @@ impl CanvasState {
|
||||
self.update_live_mesh();
|
||||
}
|
||||
|
||||
/// Merkt sich einen ungefilterten Eingabepunkt (Punkte näher als 0,03 mm am vorigen
|
||||
/// entfallen).
|
||||
fn push_raw_sample(&mut self, x: f32, y: f32, pressure: f32, timestamp: f64) {
|
||||
let position = glam::Vec2::new(x, y);
|
||||
if !position.is_finite() || !pressure.is_finite() {
|
||||
return;
|
||||
}
|
||||
if let Some(last) = self.live_raw_samples.last() {
|
||||
if (position - last.position).length_squared() < 0.09 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.live_raw_samples.push(StylusSample {
|
||||
position,
|
||||
pressure: pressure.clamp(0.0, 1.0),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -1101,6 +1249,7 @@ impl CanvasState {
|
||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||
pub fn cancel_stroke(&mut self) {
|
||||
self.live_samples.clear();
|
||||
self.live_raw_samples.clear();
|
||||
self.live_control_points.clear();
|
||||
self.live_vertices.clear();
|
||||
self.live_vertex_count = 0;
|
||||
@@ -1118,10 +1267,16 @@ impl CanvasState {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Post-Stroke Finalisierung (Corner Preservation, Druckglättung, RDP, runde Kappen)
|
||||
// Post-Stroke Finalisierung (Glättung, RDP, Ecken, runde Kappen) aus den ungefilterten
|
||||
// Punkten: Der 1€-Filter dient nur der Anzeige während des Schreibens
|
||||
let source = if self.live_raw_samples.is_empty() {
|
||||
&self.live_samples
|
||||
} else {
|
||||
&self.live_raw_samples
|
||||
};
|
||||
let stroke = spline::finalize_stroke_with_config(
|
||||
stroke_id,
|
||||
&self.live_samples,
|
||||
source,
|
||||
self.current_ink_color,
|
||||
&self.spline_config,
|
||||
);
|
||||
@@ -1131,6 +1286,7 @@ impl CanvasState {
|
||||
|
||||
// Temporären GPU-Live-Buffer vollständig leeren
|
||||
self.live_samples.clear();
|
||||
self.live_raw_samples.clear();
|
||||
self.live_control_points.clear();
|
||||
self.live_vertices.clear();
|
||||
self.live_vertex_count = 0;
|
||||
@@ -1194,9 +1350,10 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
let output = self.surface.get_current_texture()?;
|
||||
let view = output
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(render_format(&self.config)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
@@ -1217,26 +1374,42 @@ impl CanvasState {
|
||||
{
|
||||
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Ink Canvas Render Pass"),
|
||||
// Mit Multisampling ins Zwischenbild zeichnen und in die Fensterfläche auflösen
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
view: self.msaa_view.as_ref().unwrap_or(&view),
|
||||
resolve_target: self.msaa_view.as_ref().map(|_| &view),
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||
store: wgpu::StoreOp::Store,
|
||||
store: if self.msaa_view.is_some() {
|
||||
wgpu::StoreOp::Discard
|
||||
} else {
|
||||
wgpu::StoreOp::Store
|
||||
},
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.stencil_view,
|
||||
depth_ops: None,
|
||||
stencil_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0),
|
||||
store: wgpu::StoreOp::Discard,
|
||||
}),
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
let mut stencil_ref = 0;
|
||||
|
||||
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
||||
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
if let Some(buffer) = &neighbor.background_buffer {
|
||||
render_pass.set_vertex_buffer(0, neighbor.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
if let Some(buffer) = &neighbor.pattern_buffer {
|
||||
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
||||
render_pass.draw(0..6, 0..1);
|
||||
}
|
||||
self.image_layer.draw(&mut render_pass, i + 1);
|
||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||
@@ -1244,6 +1417,7 @@ impl CanvasState {
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for range in &neighbor.stroke_ranges {
|
||||
if range.end > range.start {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
@@ -1260,6 +1434,7 @@ impl CanvasState {
|
||||
|
||||
// 2. Hintergrundmuster (5mm-Gitter, liniert oder Punktraster mit Vertex-Farbe)
|
||||
if self.background_vertex_count > 0 {
|
||||
render_pass.set_pipeline(&self.pattern_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.background_buffer.slice(..));
|
||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||
}
|
||||
@@ -1274,6 +1449,7 @@ impl CanvasState {
|
||||
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
|
||||
for range in &self.committed_strokes {
|
||||
if range.end > range.start {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
@@ -1282,6 +1458,7 @@ impl CanvasState {
|
||||
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||
if self.live_vertex_count > 0 {
|
||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -1318,6 +1495,20 @@ impl CanvasState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gibt dem nächsten Strich eine eigene Stencil-Nummer: Die Strich-Pipeline zeichnet nur
|
||||
/// Pixel, die noch nicht diese Nummer tragen. Nach 255 Strichen wird der Stencil-Puffer
|
||||
/// zurückgesetzt, damit kein späterer Strich die Nummer eines früheren wiederfindet.
|
||||
fn next_stroke_stencil<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>, stencil_ref: &mut u32) {
|
||||
if *stencil_ref == u32::from(u8::MAX) {
|
||||
pass.set_pipeline(&self.stencil_reset_pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
pass.set_pipeline(&self.strip_pipeline);
|
||||
*stencil_ref = 0;
|
||||
}
|
||||
*stencil_ref += 1;
|
||||
pass.set_stencil_reference(*stencil_ref);
|
||||
}
|
||||
|
||||
/// Rendert nur die Canvas-Fläche ohne UI (z. B. für Headless-Tests).
|
||||
pub fn render_canvas_only(&mut self) -> Result<(), wgpu::SurfaceError> {
|
||||
let dummy_delta = egui::TexturesDelta::default();
|
||||
@@ -1329,6 +1520,113 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format, in das gezeichnet wird: ohne sRGB-Umrechnung. Die Farben werden also in sRGB
|
||||
/// gemischt – wie im PDF-Export, damit halbtransparente Tinte (Textmarker) am Bildschirm
|
||||
/// genauso aussieht. Tintenfarben sind sRGB-Werte, Papier- und Musterfarben rechnet der
|
||||
/// Shader aus linearen Werten um.
|
||||
fn render_format(config: &wgpu::SurfaceConfiguration) -> wgpu::TextureFormat {
|
||||
config.format.remove_srgb_suffix()
|
||||
}
|
||||
|
||||
/// Multisampling der Zeichenfläche gegen gezackte Strichkanten.
|
||||
const MSAA_SAMPLES: u32 = 4;
|
||||
|
||||
/// Zwischenbild für das Multisampling in Fenstergröße; `None` ohne Multisampling.
|
||||
fn create_msaa_view(
|
||||
device: &wgpu::Device,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
samples: u32,
|
||||
) -> Option<wgpu::TextureView> {
|
||||
(samples > 1).then(|| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("MSAA Color Target"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width.max(1),
|
||||
height: config.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: samples,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: render_format(config),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
})
|
||||
}
|
||||
|
||||
/// Format des Stencil-Puffers der Zeichenfläche.
|
||||
const STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
|
||||
|
||||
/// Stencil-Puffer in Fenstergröße (mit derselben Abtastzahl wie die Zeichenfläche).
|
||||
fn create_stencil_view(
|
||||
device: &wgpu::Device,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
samples: u32,
|
||||
) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Stroke Stencil Buffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width.max(1),
|
||||
height: config.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: samples,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: STENCIL_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
/// Stencil-Zustand mit gleicher Prüfung und Aktion für Vorder- und Rückseiten.
|
||||
fn stencil_state(
|
||||
compare: wgpu::CompareFunction,
|
||||
pass_op: wgpu::StencilOperation,
|
||||
) -> wgpu::DepthStencilState {
|
||||
let face = wgpu::StencilFaceState {
|
||||
compare,
|
||||
fail_op: wgpu::StencilOperation::Keep,
|
||||
depth_fail_op: wgpu::StencilOperation::Keep,
|
||||
pass_op,
|
||||
};
|
||||
wgpu::DepthStencilState {
|
||||
format: STENCIL_FORMAT,
|
||||
depth_write_enabled: false,
|
||||
depth_compare: wgpu::CompareFunction::Always,
|
||||
stencil: wgpu::StencilState {
|
||||
front: face,
|
||||
back: face,
|
||||
read_mask: 0xff,
|
||||
write_mask: 0xff,
|
||||
},
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pipelines ohne Strichnummer (Papier, Muster, Bilder) lassen den Stencil-Puffer unberührt.
|
||||
pub(crate) fn passive_stencil() -> wgpu::DepthStencilState {
|
||||
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Keep)
|
||||
}
|
||||
|
||||
/// Striche: nur Pixel, die die eigene Nummer noch nicht tragen, und diese dann markieren.
|
||||
fn stroke_stencil() -> wgpu::DepthStencilState {
|
||||
stencil_state(
|
||||
wgpu::CompareFunction::NotEqual,
|
||||
wgpu::StencilOperation::Replace,
|
||||
)
|
||||
}
|
||||
|
||||
/// Zurücksetzen: überall 0 eintragen.
|
||||
fn stencil_reset() -> wgpu::DepthStencilState {
|
||||
stencil_state(wgpu::CompareFunction::Always, wgpu::StencilOperation::Zero)
|
||||
}
|
||||
|
||||
/// Zu rasterndes Textfeld.
|
||||
struct TextSpec {
|
||||
text: crate::text::TextBox,
|
||||
|
||||
+458
@@ -722,6 +722,8 @@ pub struct Page {
|
||||
pub vertex_cache: Vec<InkVertex>,
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
/// Zählt die Neuaufbauten des Vertex-Caches (erkennt geänderte Nachbarseiten)
|
||||
pub cache_generation: u64,
|
||||
pub is_loaded: bool,
|
||||
pub persist: PersistState,
|
||||
pub history: HistoryStack,
|
||||
@@ -742,6 +744,7 @@ impl Page {
|
||||
is_loaded: true,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
cache_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,6 +763,7 @@ impl Page {
|
||||
is_loaded: false,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
cache_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,6 +962,7 @@ impl Page {
|
||||
}
|
||||
|
||||
self.is_dirty = false;
|
||||
self.cache_generation += 1;
|
||||
}
|
||||
|
||||
/// Berechnet die kombinierte AABB einer Menge von Strichen.
|
||||
@@ -1217,6 +1222,29 @@ impl Notebook {
|
||||
index
|
||||
}
|
||||
|
||||
/// Entfernt die aktuelle Seite und gibt sie zurück; danach ist die folgende Seite aktuell
|
||||
/// (bzw. die vorige, wenn es die letzte war). Die einzige Seite eines Notizbuchs bleibt
|
||||
/// stehen (`None`).
|
||||
pub fn remove_current_page(&mut self) -> Option<Page> {
|
||||
if self.pages.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let page = self.pages.remove(self.current_page_index);
|
||||
self.load_warnings.remove(&page.id);
|
||||
self.current_page_index = self.current_page_index.min(self.pages.len() - 1);
|
||||
Some(page)
|
||||
}
|
||||
|
||||
/// Setzt eine mit [`Self::remove_current_page`] entfernte Seite wieder an `index` ein
|
||||
/// (höchstens ans Ende) und macht sie zur aktuellen Seite; gibt ihren Index zurück.
|
||||
pub fn restore_page(&mut self, index: usize, page: Page) -> usize {
|
||||
let index = index.min(self.pages.len());
|
||||
self.next_page_id = self.next_page_id.max(page.id + 1);
|
||||
self.pages.insert(index, page);
|
||||
self.current_page_index = index;
|
||||
index
|
||||
}
|
||||
|
||||
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
|
||||
/// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
|
||||
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
|
||||
@@ -1292,6 +1320,298 @@ impl Default for Notebook {
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstand zum oberen Rand, mit dem ein Objekt auf der nächsten Seite landet, wenn es beim
|
||||
/// Platzschaffen über das Seitenende rutschen würde (10 mm).
|
||||
pub const SPACE_PAGE_BREAK_MARGIN: f32 = 10.0 * UNITS_PER_MM;
|
||||
|
||||
/// Ein beim Platzschaffen verschobener Strich: vorher und nachher, jeweils mit Seite.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StrokeMove {
|
||||
pub from_page: u64,
|
||||
pub before: Stroke,
|
||||
pub to_page: u64,
|
||||
pub after: Stroke,
|
||||
}
|
||||
|
||||
/// Für das Platzschaffen neu eingefügte Seite.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AddedPage {
|
||||
pub id: u64,
|
||||
/// Seite, hinter der sie liegt
|
||||
pub after_page: u64,
|
||||
pub size: glam::Vec2,
|
||||
pub background: PageBackground,
|
||||
}
|
||||
|
||||
/// Ein Platzschaffen-Schritt (für Rückgängig und Wiederholen).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpaceShift {
|
||||
pub moves: Vec<StrokeMove>,
|
||||
pub added_pages: Vec<AddedPage>,
|
||||
}
|
||||
|
||||
impl Notebook {
|
||||
/// Seiten, die beim Platzschaffen auf Seite `page_index` mitrutschen: sie selbst und die
|
||||
/// folgenden bis vor die nächste Seite mit importiertem PDF (deren Notizen gehören zur
|
||||
/// PDF-Seite). `None` auf einer PDF-Seite.
|
||||
pub fn space_run(&self, page_index: usize) -> Option<std::ops::Range<usize>> {
|
||||
let page = self.pages.get(page_index)?;
|
||||
if page.pdf_background.is_some() {
|
||||
return None;
|
||||
}
|
||||
let end = (page_index + 1..self.pages.len())
|
||||
.find(|&i| self.pages[i].pdf_background.is_some())
|
||||
.unwrap_or(self.pages.len());
|
||||
Some(page_index..end)
|
||||
}
|
||||
|
||||
/// Wie weit sich der Inhalt unter `y` auf Seite `page_index` höchstens nach oben schieben
|
||||
/// lässt: bis an das unterste Objekt darüber (bzw. den oberen Seitenrand).
|
||||
pub fn space_up_limit(&self, page_index: usize, y: f32) -> f32 {
|
||||
let Some(page) = self.pages.get(page_index) else {
|
||||
return 0.0;
|
||||
};
|
||||
let bottom = page
|
||||
.strokes
|
||||
.iter()
|
||||
.filter(|s| s.aabb.min.y < y)
|
||||
.map(|s| s.aabb.max.y)
|
||||
.fold(0.0_f32, f32::max);
|
||||
(y - bottom).max(0.0)
|
||||
}
|
||||
|
||||
/// Platz schaffen: Alles, was auf Seite `page_index` unterhalb von `y` beginnt, und der
|
||||
/// ganze Inhalt der folgenden Seiten (siehe [`Self::space_run`]) rutscht um `dy` nach unten
|
||||
/// (negativ: nach oben, höchstens bis [`Self::space_up_limit`]). Die Seiten gelten dabei
|
||||
/// als durchgehende Spalte: Was über ein Seitenende hinausragen würde, beginnt auf der
|
||||
/// nächsten Seite (mit [`SPACE_PAGE_BREAK_MARGIN`] Abstand), alles danach rutscht
|
||||
/// entsprechend weiter; fehlende Seiten werden angehängt. Der Schritt landet im Verlauf
|
||||
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
|
||||
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
|
||||
let run = self.space_run(page_index)?;
|
||||
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
|
||||
return None;
|
||||
}
|
||||
let dy = if dy < 0.0 {
|
||||
dy.max(-self.space_up_limit(page_index, y))
|
||||
} else {
|
||||
dy
|
||||
};
|
||||
if dy.abs() < 1.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Seiten als durchgehende Spalte: Lage jeder Seite (auch noch anzulegender)
|
||||
let last_size = self.pages[run.end - 1].size;
|
||||
let mut tops = Vec::new();
|
||||
let mut heights = Vec::new();
|
||||
let mut top = 0.0;
|
||||
for page in &self.pages[run.clone()] {
|
||||
tops.push(top);
|
||||
heights.push(page.size.y);
|
||||
top += page.size.y;
|
||||
}
|
||||
|
||||
// Verschoben wird alles unterhalb von y, in der Reihenfolge von oben nach unten
|
||||
let mut movable: Vec<(usize, f32, Stroke)> = Vec::new();
|
||||
for (k, page) in self.pages[run.clone()].iter().enumerate() {
|
||||
for stroke in &page.strokes {
|
||||
if k > 0 || stroke.aabb.min.y >= y {
|
||||
movable.push((k, tops[k] + stroke.aabb.min.y, stroke.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if movable.is_empty() {
|
||||
return None;
|
||||
}
|
||||
movable.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
|
||||
let mut extra = 0.0_f32;
|
||||
let mut targets = Vec::with_capacity(movable.len());
|
||||
for (_, global_top, stroke) in &movable {
|
||||
let height = stroke.aabb.max.y - stroke.aabb.min.y;
|
||||
let mut new_top = global_top + dy + extra;
|
||||
let mut target = 0;
|
||||
for _ in 0..2 {
|
||||
// Seite, auf der das Objekt beginnt (bei Bedarf weitere Seiten)
|
||||
target = 0;
|
||||
while target + 1 < tops.len() && tops[target + 1] <= new_top {
|
||||
target += 1;
|
||||
}
|
||||
while tops[target] + heights[target] <= new_top {
|
||||
tops.push(tops[target] + heights[target]);
|
||||
heights.push(last_size.y);
|
||||
target += 1;
|
||||
}
|
||||
let page_end = tops[target] + heights[target];
|
||||
let fits_on_a_page = height <= heights[target] - 2.0 * SPACE_PAGE_BREAK_MARGIN;
|
||||
if new_top + height <= page_end || !fits_on_a_page {
|
||||
break;
|
||||
}
|
||||
// Ragt über das Seitenende: auf die nächste Seite, alles Folgende rutscht mit
|
||||
let margin = if dy > 0.0 {
|
||||
SPACE_PAGE_BREAK_MARGIN
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let bump = page_end + margin - new_top;
|
||||
extra += bump;
|
||||
new_top += bump;
|
||||
}
|
||||
targets.push((target, new_top));
|
||||
}
|
||||
|
||||
// Fehlende Seiten hinter dem Abschnitt einfügen
|
||||
let needed = targets.iter().map(|(t, _)| t + 1).max().unwrap_or(0);
|
||||
let mut added_pages = Vec::new();
|
||||
let background = self.pages[run.end - 1].background;
|
||||
for k in run.len()..needed {
|
||||
let after_page = self.pages[run.start + k - 1].id;
|
||||
let id = self.next_page_id;
|
||||
self.next_page_id += 1;
|
||||
self.pages.insert(
|
||||
run.start + k,
|
||||
Page::new(id, background).with_size(last_size),
|
||||
);
|
||||
added_pages.push(AddedPage {
|
||||
id,
|
||||
after_page,
|
||||
size: last_size,
|
||||
background,
|
||||
});
|
||||
}
|
||||
|
||||
// Umbuchen
|
||||
let mut moves = Vec::with_capacity(movable.len());
|
||||
for ((k, _, before), (target, new_top)) in movable.into_iter().zip(targets) {
|
||||
let mut after = before.clone();
|
||||
after.translate(glam::Vec2::new(
|
||||
0.0,
|
||||
new_top - tops[target] - before.aabb.min.y,
|
||||
));
|
||||
moves.push(StrokeMove {
|
||||
from_page: self.pages[run.start + k].id,
|
||||
before,
|
||||
to_page: self.pages[run.start + target].id,
|
||||
after,
|
||||
});
|
||||
}
|
||||
let shift = SpaceShift { moves, added_pages };
|
||||
let changed = self.apply_space_moves(&shift, false);
|
||||
self.pages[page_index]
|
||||
.history
|
||||
.push(HistoryAction::SpaceShift(Box::new(shift)));
|
||||
Some(changed)
|
||||
}
|
||||
|
||||
/// Bucht die Striche eines Platzschaffen-Schritts um (`undo`: zurück) und gibt die
|
||||
/// geänderten Seiten zurück. Später geänderte oder gelöschte Striche bleiben unberührt.
|
||||
fn apply_space_moves(&mut self, shift: &SpaceShift, undo: bool) -> Vec<u64> {
|
||||
let mut changed = Vec::new();
|
||||
let index_of = |pages: &[Page], id: u64| pages.iter().position(|p| p.id == id);
|
||||
for m in &shift.moves {
|
||||
let (from, old, to, new) = if undo {
|
||||
(m.to_page, &m.after, m.from_page, &m.before)
|
||||
} else {
|
||||
(m.from_page, &m.before, m.to_page, &m.after)
|
||||
};
|
||||
let (Some(from_i), Some(to_i)) =
|
||||
(index_of(&self.pages, from), index_of(&self.pages, to))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(pos) = self.pages[from_i]
|
||||
.strokes
|
||||
.iter()
|
||||
.position(|s| s.id == old.id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.pages[from_i].strokes.remove(pos);
|
||||
self.pages[to_i].strokes.push(new.clone());
|
||||
for i in [from_i, to_i] {
|
||||
self.pages[i].is_dirty = true;
|
||||
if !changed.contains(&self.pages[i].id) {
|
||||
changed.push(self.pages[i].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
|
||||
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
|
||||
pub fn undo(&mut self) -> Option<Vec<u64>> {
|
||||
let index = self.current_page_index;
|
||||
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
|
||||
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut changed = self.apply_space_moves(&shift, true);
|
||||
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
|
||||
for added in shift.added_pages.iter().rev() {
|
||||
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
|
||||
if self.pages[i].strokes.is_empty() && self.pages.len() > 1 {
|
||||
self.pages.remove(i);
|
||||
if self.current_page_index >= i && self.current_page_index > 0 {
|
||||
self.current_page_index -= 1;
|
||||
}
|
||||
changed.retain(|&id| id != added.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Der Schritt gehört weiter zur aktuellen Seite (sie selbst wird nie entfernt)
|
||||
let owner = self.current_page_index;
|
||||
self.pages[owner]
|
||||
.history
|
||||
.redo_stack
|
||||
.push(HistoryAction::SpaceShift(shift));
|
||||
return Some(changed);
|
||||
}
|
||||
let page = &mut self.pages[index];
|
||||
page.undo().then(|| vec![page.id])
|
||||
}
|
||||
|
||||
/// Stellt die zuletzt rückgängig gemachte Aktion der aktuellen Seite wieder her.
|
||||
pub fn redo(&mut self) -> Option<Vec<u64>> {
|
||||
let index = self.current_page_index;
|
||||
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
|
||||
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
// Entfernte Seiten wieder einfügen
|
||||
for added in &shift.added_pages {
|
||||
if self.pages.iter().any(|p| p.id == added.id) {
|
||||
continue;
|
||||
}
|
||||
let at = self
|
||||
.pages
|
||||
.iter()
|
||||
.position(|p| p.id == added.after_page)
|
||||
.map_or(self.pages.len(), |i| i + 1);
|
||||
self.pages.insert(
|
||||
at,
|
||||
Page::new(added.id, added.background).with_size(added.size),
|
||||
);
|
||||
if at <= self.current_page_index {
|
||||
self.current_page_index += 1;
|
||||
}
|
||||
}
|
||||
let changed = self.apply_space_moves(&shift, false);
|
||||
let owner = self.current_page_index;
|
||||
self.pages[owner]
|
||||
.history
|
||||
.undo_stack
|
||||
.push(HistoryAction::SpaceShift(shift));
|
||||
return Some(changed);
|
||||
}
|
||||
let page = &mut self.pages[index];
|
||||
page.redo().then(|| vec![page.id])
|
||||
}
|
||||
}
|
||||
|
||||
/// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
|
||||
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
|
||||
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
|
||||
@@ -1982,6 +2302,144 @@ mod tests {
|
||||
assert!(new.pdf_background.is_none());
|
||||
}
|
||||
|
||||
/// Senkrechter Strich bei x = 300 von `y` bis `y + h`.
|
||||
fn block(id: u64, y: f32, h: f32) -> Stroke {
|
||||
let samples = [y, y + h]
|
||||
.map(|y| StylusSample {
|
||||
position: glam::Vec2::new(300.0, y),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.to_vec();
|
||||
Stroke::new(id, samples, Vec::new(), [0.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
fn top_of(nb: &Notebook, page: usize, id: u64) -> Option<f32> {
|
||||
nb.pages[page]
|
||||
.strokes
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|s| s.aabb.min.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_space_moves_content_below_and_following_pages() {
|
||||
let mut nb = Notebook::new();
|
||||
nb.add_page(PageBackground::Lined);
|
||||
let a4 = A4_SIZE.y;
|
||||
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
|
||||
nb.pages[1].strokes = vec![block(3, 500.0, 100.0)];
|
||||
|
||||
// 30 mm Platz bei y = 800 auf Seite 1
|
||||
let changed = nb.make_space(0, 800.0, 300.0).unwrap();
|
||||
assert_eq!(top_of(&nb, 0, 1), Some(200.0), "darüber bleibt stehen");
|
||||
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
|
||||
assert_eq!(
|
||||
top_of(&nb, 1, 3),
|
||||
Some(800.0),
|
||||
"die nächste Seite rutscht mit"
|
||||
);
|
||||
assert_eq!(changed.len(), 2);
|
||||
assert_eq!(nb.pages.len(), 2);
|
||||
|
||||
// Rückgängig und wiederholen über beide Seiten
|
||||
nb.current_page_index = 0;
|
||||
assert!(nb.undo().is_some());
|
||||
assert_eq!(top_of(&nb, 0, 2), Some(1000.0));
|
||||
assert_eq!(top_of(&nb, 1, 3), Some(500.0));
|
||||
assert!(nb.redo().is_some());
|
||||
assert_eq!(top_of(&nb, 0, 2), Some(1300.0));
|
||||
assert_eq!(top_of(&nb, 1, 3), Some(800.0));
|
||||
|
||||
// Über das Seitenende: auf die nächste Seite mit Abstand, das Folgende rutscht weiter
|
||||
let mut nb = Notebook::new();
|
||||
nb.pages[0].strokes = vec![block(1, a4 - 400.0, 200.0), block(2, a4 - 150.0, 100.0)];
|
||||
nb.make_space(0, a4 - 500.0, 300.0).unwrap();
|
||||
assert_eq!(nb.pages.len(), 2, "eine Seite wurde angehängt");
|
||||
assert_eq!(nb.pages[1].background, nb.pages[0].background);
|
||||
assert!(nb.pages[0].strokes.is_empty());
|
||||
let first = top_of(&nb, 1, 1).unwrap();
|
||||
assert!((first - SPACE_PAGE_BREAK_MARGIN).abs() < 1e-3, "{first}");
|
||||
// Abstand zwischen beiden bleibt (50 Einheiten unter dem Ende des ersten)
|
||||
assert!((top_of(&nb, 1, 2).unwrap() - (first + 250.0)).abs() < 1e-3);
|
||||
// Rückgängig entfernt die angehängte leere Seite
|
||||
assert!(nb.undo().is_some());
|
||||
assert_eq!(nb.pages.len(), 1);
|
||||
assert_eq!(top_of(&nb, 0, 2), Some(a4 - 150.0));
|
||||
assert!(nb.redo().is_some());
|
||||
assert_eq!(nb.pages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_space_upwards_only_closes_empty_space() {
|
||||
let mut nb = Notebook::new();
|
||||
nb.pages[0].strokes = vec![block(1, 200.0, 100.0), block(2, 1000.0, 100.0)];
|
||||
// Nach oben höchstens bis an das Objekt darüber (Unterkante 300)
|
||||
assert_eq!(nb.space_up_limit(0, 900.0), 600.0);
|
||||
nb.make_space(0, 900.0, -2000.0).unwrap();
|
||||
assert_eq!(top_of(&nb, 0, 2), Some(400.0));
|
||||
assert_eq!(top_of(&nb, 0, 1), Some(200.0));
|
||||
// Kein Platz mehr: nichts zu tun
|
||||
assert!(nb.make_space(0, 300.5, -50.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_space_stops_before_pdf_pages() {
|
||||
let mut nb = Notebook::new();
|
||||
nb.add_page(PageBackground::Blank);
|
||||
nb.add_page(PageBackground::Blank);
|
||||
nb.pages[1].pdf_background = Some(PdfBackground {
|
||||
asset: "x.pdf".to_string(),
|
||||
page: 0,
|
||||
width_pt: 595.0,
|
||||
height_pt: 842.0,
|
||||
});
|
||||
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
|
||||
nb.pages[2].strokes = vec![block(2, 500.0, 100.0)];
|
||||
assert!(nb.space_run(1).is_none(), "nicht auf PDF-Seiten");
|
||||
assert_eq!(nb.space_run(0), Some(0..1));
|
||||
nb.make_space(0, 900.0, 300.0).unwrap();
|
||||
assert_eq!(top_of(&nb, 0, 1), Some(1300.0));
|
||||
assert_eq!(
|
||||
top_of(&nb, 2, 2),
|
||||
Some(500.0),
|
||||
"hinter der PDF-Seite bleibt alles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_and_restore_current_page() {
|
||||
let mut nb = Notebook::new();
|
||||
// Die einzige Seite bleibt
|
||||
assert!(nb.remove_current_page().is_none());
|
||||
nb.add_page(PageBackground::Lined);
|
||||
nb.add_page(PageBackground::DotGrid);
|
||||
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
|
||||
|
||||
// Mittlere Seite: danach ist die folgende aktuell
|
||||
nb.current_page_index = 1;
|
||||
let removed = nb.remove_current_page().unwrap();
|
||||
assert_eq!(removed.id, ids[1]);
|
||||
assert_eq!(
|
||||
nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(),
|
||||
[ids[0], ids[2]]
|
||||
);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
|
||||
// Wiederherstellen an der alten Stelle
|
||||
assert_eq!(nb.restore_page(1, removed), 1);
|
||||
assert_eq!(nb.pages.iter().map(|p| p.id).collect::<Vec<_>>(), ids);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
|
||||
// Letzte Seite: danach ist die vorige aktuell
|
||||
nb.current_page_index = 2;
|
||||
let last = nb.remove_current_page().unwrap();
|
||||
assert_eq!(last.id, ids[2]);
|
||||
assert_eq!(nb.current_page_index, 1);
|
||||
// Neue Seiten bekommen keine schon vergebene Kennung
|
||||
assert!(nb.next_page_id > ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_page_when_writing_at_bottom_of_last_page() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+235
-36
@@ -20,7 +20,7 @@ use printpdf::path::{PaintMode, WindingOrder};
|
||||
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
||||
|
||||
use crate::assets::AssetStore;
|
||||
use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
|
||||
use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING};
|
||||
use crate::document::{
|
||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
};
|
||||
@@ -155,14 +155,16 @@ fn end_page_layer(layer: &printpdf::PdfLayerReference) {
|
||||
}
|
||||
|
||||
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
|
||||
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
|
||||
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben);
|
||||
/// `pattern`: Farbe von Karos, Linien und Punkten.
|
||||
pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
||||
notebook: &Notebook,
|
||||
path: P,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
pattern: PatternColor,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?;
|
||||
let bytes = export_notebook_to_bytes(notebook, strategy, assets, pattern)?;
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
std::io::Write::write_all(&mut writer, &bytes)?;
|
||||
@@ -175,9 +177,10 @@ pub fn export_notebook_to_bytes(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
pattern: PatternColor,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
|
||||
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||
|
||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||
let underlays = pdf_underlays(notebook);
|
||||
@@ -202,8 +205,8 @@ pub fn export_notebook_to_bytes(
|
||||
);
|
||||
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 doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
||||
let remaining: Vec<_> = underlays
|
||||
.into_iter()
|
||||
.filter(|u| !raster.contains(&u.target_page))
|
||||
@@ -244,6 +247,77 @@ fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
|
||||
/// gerundet); `None` für deckende Farben.
|
||||
fn alpha_state_name(alpha: f32) -> Option<String> {
|
||||
let percent = (alpha.clamp(0.0, 1.0) * 100.0).round() as u32;
|
||||
(percent < 100).then(|| format!("InkAlpha{percent}"))
|
||||
}
|
||||
|
||||
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
|
||||
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
|
||||
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
|
||||
/// unverändert.
|
||||
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
use printpdf::lopdf::{Dictionary, Object};
|
||||
|
||||
let mut states: Vec<(String, f32)> = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.strokes)
|
||||
.filter(|s| !s.is_object())
|
||||
.filter_map(|s| alpha_state_name(s.color[3]).map(|name| (name, s.color[3])))
|
||||
.collect();
|
||||
if states.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
states.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
states.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
||||
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
|
||||
for page_id in page_ids {
|
||||
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
|
||||
let ext_g_state = dict_object_id(&mut doc, resources, b"ExtGState")?;
|
||||
let dict = doc.get_object_mut(ext_g_state)?.as_dict_mut()?;
|
||||
for (name, alpha) in &states {
|
||||
let alpha = (alpha.clamp(0.0, 1.0) * 100.0).round() / 100.0;
|
||||
let mut state = Dictionary::new();
|
||||
state.set("Type", Object::Name(b"ExtGState".to_vec()));
|
||||
state.set("ca", Object::Real(alpha));
|
||||
state.set("CA", Object::Real(alpha));
|
||||
dict.set(name.as_bytes().to_vec(), Object::Dictionary(state));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
|
||||
/// Wörterbuch wird dafür zu einem eigenen Objekt.
|
||||
fn dict_object_id(
|
||||
doc: &mut printpdf::lopdf::Document,
|
||||
owner: printpdf::lopdf::ObjectId,
|
||||
key: &[u8],
|
||||
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
|
||||
use printpdf::lopdf::{Dictionary, Object};
|
||||
|
||||
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
|
||||
if let Some(Object::Reference(id)) = entry {
|
||||
return Ok(id);
|
||||
}
|
||||
let dict = match entry {
|
||||
Some(Object::Dictionary(dict)) => dict,
|
||||
_ => Dictionary::new(),
|
||||
};
|
||||
let id = doc.add_object(dict);
|
||||
doc.get_object_mut(owner)?
|
||||
.as_dict_mut()?
|
||||
.set(key.to_vec(), Object::Reference(id));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -289,11 +363,13 @@ pub fn generate_pdf_document(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
pattern: PatternColor,
|
||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||
generate_pdf_document_with(
|
||||
notebook,
|
||||
strategy,
|
||||
assets,
|
||||
pattern,
|
||||
&std::collections::HashSet::new(),
|
||||
)
|
||||
}
|
||||
@@ -304,6 +380,7 @@ fn generate_pdf_document_with(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
pattern: PatternColor,
|
||||
raster_pdf_pages: &std::collections::HashSet<usize>,
|
||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||
if notebook.pages.is_empty() {
|
||||
@@ -338,7 +415,7 @@ fn generate_pdf_document_with(
|
||||
doc.add_bookmark(title.clone(), page_idx);
|
||||
}
|
||||
let page_ref = doc.get_page(page_idx);
|
||||
render_background_layer(&page_ref.get_layer(layer_bg), page);
|
||||
render_background_layer(&page_ref.get_layer(layer_bg), page, pattern);
|
||||
let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| {
|
||||
let layer = page_ref.add_layer(name);
|
||||
begin_page_layer(&layer, page);
|
||||
@@ -361,10 +438,18 @@ fn generate_pdf_document_with(
|
||||
}
|
||||
|
||||
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
||||
fn render_background_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
|
||||
fn render_background_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
pattern: PatternColor,
|
||||
) {
|
||||
let background = page.background;
|
||||
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
|
||||
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
|
||||
// Exportiert wird immer auf weißem Papier; die Musterfarbe ist linear, PDF erwartet sRGB
|
||||
let [r, g, b, _] = pattern
|
||||
.color(crate::background::PaperTheme::White)
|
||||
.map(crate::background::linear_to_srgb);
|
||||
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
||||
layer.set_outline_color(grid_color.clone());
|
||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||
layer.set_fill_color(grid_color);
|
||||
@@ -425,7 +510,7 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
|
||||
|
||||
PageBackground::DotGrid => {
|
||||
let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm
|
||||
let half_dot = 0.2; // 0.4 mm Kantenlänge
|
||||
let half_dot = DOT_SIZE / 20.0; // Kantenlänge in mm wie am Bildschirm
|
||||
|
||||
let mut y = spacing_mm;
|
||||
while y < page_height_mm {
|
||||
@@ -456,6 +541,17 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
if stroke.is_object() {
|
||||
continue;
|
||||
}
|
||||
// Halbtransparente Tinte (Textmarker): Deckkraft über einen Grafikzustand, den
|
||||
// `add_alpha_states` anlegt. Jeder Strich ist ein einziger Pfad und deckt daher jede
|
||||
// Stelle nur einmal, auch wo er sich selbst überlappt.
|
||||
let alpha_state = alpha_state_name(stroke.color[3]);
|
||||
if let Some(name) = &alpha_state {
|
||||
layer.save_graphics_state();
|
||||
layer.add_operation(printpdf::lopdf::content::Operation::new(
|
||||
"gs",
|
||||
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
|
||||
));
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
} else {
|
||||
@@ -468,6 +564,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
||||
}
|
||||
}
|
||||
}
|
||||
if alpha_state.is_some() {
|
||||
layer.restore_graphics_state();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,6 +860,10 @@ fn render_recognized_shape(
|
||||
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
||||
layer.set_outline_color(stroke_color.clone());
|
||||
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
||||
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
|
||||
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
|
||||
|
||||
match shape {
|
||||
RecognizedShape::Line { start, end } => {
|
||||
@@ -930,6 +1033,59 @@ fn render_recognized_shape(
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||
} => {
|
||||
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let to_pdf = |p: &glam::Vec2| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
};
|
||||
let mut heads = Vec::new();
|
||||
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||
let dir = (pos - neg).normalize_or_zero();
|
||||
let pos_head = crate::geometry::arrow_head(
|
||||
pos,
|
||||
dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(pos - *origin).length(),
|
||||
);
|
||||
let neg_head = crate::geometry::arrow_head(
|
||||
neg,
|
||||
-dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(neg - *origin).length(),
|
||||
);
|
||||
let (from, to) = if *with_arrows {
|
||||
(neg_head.0, pos_head.0)
|
||||
} else {
|
||||
(neg, pos)
|
||||
};
|
||||
layer.add_line(Line {
|
||||
points: [from, to].iter().map(to_pdf).collect(),
|
||||
is_closed: false,
|
||||
});
|
||||
heads.push((pos, pos_head));
|
||||
heads.push((neg, neg_head));
|
||||
}
|
||||
if *with_arrows {
|
||||
layer.set_fill_color(stroke_color);
|
||||
for (tip, (_, left, right)) in heads {
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![[tip, left, right].iter().map(to_pdf).collect()],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: None,
|
||||
} => {
|
||||
// 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)
|
||||
@@ -954,17 +1110,19 @@ fn render_recognized_shape(
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||
for end in [y_line_end, x_line_end] {
|
||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||
layer.add_line(Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(ex), Mm(ey)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
});
|
||||
}
|
||||
// Ein einziger Linienzug, damit die Ecke am Ursprung als Linienverbindung
|
||||
// geschlossen gezeichnet wird statt als zwei stumpfe Enden
|
||||
let points = [y_line_end, *origin, x_line_end]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_line(Line {
|
||||
points,
|
||||
is_closed: false,
|
||||
});
|
||||
|
||||
// Pfeilspitzen als gefüllte Dreiecke
|
||||
if *with_arrows {
|
||||
@@ -1259,8 +1417,13 @@ mod tests {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
||||
|
||||
let export_res =
|
||||
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon, None);
|
||||
let export_res = export_notebook_to_pdf(
|
||||
¬ebook,
|
||||
&pdf_path,
|
||||
ExportStrategy::FilledPolygon,
|
||||
None,
|
||||
PatternColor::default(),
|
||||
);
|
||||
assert!(
|
||||
export_res.is_ok(),
|
||||
"PDF-Export zu Datei muss erfolgreich sein"
|
||||
@@ -1286,7 +1449,12 @@ mod tests {
|
||||
);
|
||||
|
||||
// 3. Export als Bytes testen (Strategie B: BezierCurve)
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve, None);
|
||||
let bytes_res = export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::BezierCurve,
|
||||
None,
|
||||
PatternColor::default(),
|
||||
);
|
||||
assert!(bytes_res.is_ok());
|
||||
let bytes = bytes_res.unwrap();
|
||||
assert!(bytes.len() > 1024);
|
||||
@@ -1335,6 +1503,7 @@ mod tests {
|
||||
x_axis_end: glam::Vec2::new(800.0, 1500.0),
|
||||
y_axis_end: glam::Vec2::new(300.0, 1000.0),
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
},
|
||||
);
|
||||
page.add_stroke(coords_stroke);
|
||||
@@ -1409,7 +1578,12 @@ mod tests {
|
||||
);
|
||||
page.add_stroke(octahedron_stroke);
|
||||
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None);
|
||||
let bytes_res = export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::FilledPolygon,
|
||||
None,
|
||||
PatternColor::default(),
|
||||
);
|
||||
assert!(bytes_res.is_ok(), "Shape export must succeed");
|
||||
let bytes = bytes_res.unwrap();
|
||||
assert!(bytes.len() > 1024);
|
||||
@@ -1478,9 +1652,13 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
let bytes =
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets))
|
||||
.expect("Export mit Bildern");
|
||||
let bytes = export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::FilledPolygon,
|
||||
Some(&assets),
|
||||
PatternColor::default(),
|
||||
)
|
||||
.expect("Export mit Bildern");
|
||||
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
|
||||
assert!(
|
||||
contains(b"/DCTDecode"),
|
||||
@@ -1504,7 +1682,13 @@ mod tests {
|
||||
assert_eq!(image_objects, 3);
|
||||
|
||||
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
|
||||
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
||||
assert!(export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::FilledPolygon,
|
||||
None,
|
||||
PatternColor::default()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1516,9 +1700,13 @@ mod tests {
|
||||
crate::storage::load_all_pages(&dir, &mut notebook).unwrap();
|
||||
let assets = AssetStore::new(&dir);
|
||||
|
||||
let bytes =
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets))
|
||||
.unwrap();
|
||||
let bytes = export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::FilledPolygon,
|
||||
Some(&assets),
|
||||
PatternColor::default(),
|
||||
)
|
||||
.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() {
|
||||
@@ -1563,7 +1751,13 @@ mod tests {
|
||||
assert!(!red_at(&exported, 5, 5));
|
||||
|
||||
// Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche)
|
||||
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
||||
assert!(export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::FilledPolygon,
|
||||
None,
|
||||
PatternColor::default()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1574,8 +1768,13 @@ mod tests {
|
||||
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(¬ebook, ExportStrategy::FilledPolygon, None).unwrap();
|
||||
let bytes = export_notebook_to_bytes(
|
||||
¬ebook,
|
||||
ExportStrategy::FilledPolygon,
|
||||
None,
|
||||
PatternColor::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
||||
let catalog = doc.catalog().unwrap();
|
||||
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
|
||||
|
||||
+91
-15
@@ -10,10 +10,13 @@ use std::f32::consts::PI;
|
||||
|
||||
/// Standard-Minimal-Grenzfrequenz in Hz (reduziert Jitter bei niedrigen Geschwindigkeiten)
|
||||
pub const DEFAULT_FC_MIN: f32 = 4.5;
|
||||
/// Standard-Geschwindigkeitskoeffizient (reduziert Latenz bei schnellen Bewegungen)
|
||||
pub const DEFAULT_BETA: f32 = 0.05;
|
||||
/// Standard-Grenzfrequenz für die Ableitung in Hz
|
||||
pub const DEFAULT_D_CUTOFF: f32 = 1.0;
|
||||
/// Standard-Geschwindigkeitskoeffizient (Hz je Seiteneinheit/s): Bei 400 Einheiten/s
|
||||
/// (4 cm/s, zügige Handschrift) liegt die Grenzfrequenz bei rund 65 Hz, kleine Schleifen
|
||||
/// werden kaum noch verkleinert
|
||||
pub const DEFAULT_BETA: f32 = 0.15;
|
||||
/// Standard-Grenzfrequenz für die Geschwindigkeitsschätzung in Hz (reagiert nach wenigen
|
||||
/// Millisekunden auf schnellere Bewegung)
|
||||
pub const DEFAULT_D_CUTOFF: f32 = 8.0;
|
||||
|
||||
/// Berechnet den Glättungsfaktor alpha = 1 / (1 + tau / dt) = 2*pi*fc*dt / (2*pi*fc*dt + 1).
|
||||
///
|
||||
@@ -157,13 +160,18 @@ pub struct FilteredPoint {
|
||||
}
|
||||
|
||||
/// Adaptiver 1€-Filter für 2D-Position (x, y) und Stylus-Druck p ∈ [0.0, 1.0].
|
||||
///
|
||||
/// Die Position wird mit der Geschwindigkeit in der Ebene gefiltert (nicht je Achse):
|
||||
/// Auf einer Kreisbahn ist eine Achse an den Umkehrpunkten in Ruhe, eine Filterung je
|
||||
/// Achse würde dort stark glätten und Schleifen zusammenziehen.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OneEuroFilter {
|
||||
pub fc_min: f32,
|
||||
pub beta: f32,
|
||||
pub d_cutoff: f32,
|
||||
x_filter: OneEuroFilter1D,
|
||||
y_filter: OneEuroFilter1D,
|
||||
pos_prev: Option<glam::Vec2>,
|
||||
speed_prev: f32,
|
||||
t_prev: Option<f64>,
|
||||
p_filter: OneEuroFilter1D,
|
||||
last_output: Option<(f32, f32, f32)>,
|
||||
}
|
||||
@@ -175,8 +183,9 @@ impl OneEuroFilter {
|
||||
fc_min,
|
||||
beta,
|
||||
d_cutoff,
|
||||
x_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||
y_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||
pos_prev: None,
|
||||
speed_prev: 0.0,
|
||||
t_prev: None,
|
||||
p_filter: OneEuroFilter1D::new(fc_min, beta, d_cutoff),
|
||||
last_output: None,
|
||||
}
|
||||
@@ -184,8 +193,9 @@ impl OneEuroFilter {
|
||||
|
||||
/// Setzt den Filter vollständig zurück (z. B. bei neuem Stroke).
|
||||
pub fn reset(&mut self) {
|
||||
self.x_filter.reset();
|
||||
self.y_filter.reset();
|
||||
self.pos_prev = None;
|
||||
self.speed_prev = 0.0;
|
||||
self.t_prev = None;
|
||||
self.p_filter.reset();
|
||||
self.last_output = None;
|
||||
}
|
||||
@@ -202,8 +212,8 @@ impl OneEuroFilter {
|
||||
// Druck immer auf [0.0, 1.0] klemmen
|
||||
let p_in = pressure.clamp(0.0, 1.0);
|
||||
|
||||
let x_out = self.x_filter.filter(x, t);
|
||||
let y_out = self.y_filter.filter(y, t);
|
||||
let pos = self.filter_position(glam::Vec2::new(x, y), t);
|
||||
let (x_out, y_out) = (pos.x, pos.y);
|
||||
let p_out = self.p_filter.filter(p_in, t).clamp(0.0, 1.0);
|
||||
|
||||
let out = (x_out, y_out, p_out);
|
||||
@@ -211,6 +221,51 @@ impl OneEuroFilter {
|
||||
out
|
||||
}
|
||||
|
||||
/// 1€-Filter der Position mit der Geschwindigkeit in der Ebene.
|
||||
fn filter_position(&mut self, x: glam::Vec2, t: f64) -> glam::Vec2 {
|
||||
let (Some(prev), Some(prev_t)) = (self.pos_prev, self.t_prev) else {
|
||||
self.pos_prev = Some(x);
|
||||
self.speed_prev = 0.0;
|
||||
self.t_prev = Some(t);
|
||||
return x;
|
||||
};
|
||||
let raw_dt = t - prev_t;
|
||||
// Lange Pause: neu beginnen statt über die Pause hinweg zu glätten
|
||||
if raw_dt > 1.0 {
|
||||
self.pos_prev = Some(x);
|
||||
self.speed_prev = 0.0;
|
||||
self.t_prev = Some(t);
|
||||
return x;
|
||||
}
|
||||
let dt = if raw_dt <= 0.0 || !raw_dt.is_finite() {
|
||||
1e-4_f32
|
||||
} else {
|
||||
raw_dt as f32
|
||||
};
|
||||
self.t_prev = Some(t);
|
||||
|
||||
let mut speed = (x - prev).length() / dt;
|
||||
if !speed.is_finite() {
|
||||
speed = 0.0;
|
||||
}
|
||||
let a_d = smoothing_factor(dt, self.d_cutoff);
|
||||
let speed_hat = a_d * speed + (1.0 - a_d) * self.speed_prev;
|
||||
|
||||
let fc = (self.fc_min + self.beta * speed_hat).max(self.fc_min);
|
||||
let a = smoothing_factor(dt, fc);
|
||||
let mut out = prev + (x - prev) * a;
|
||||
if !out.is_finite() {
|
||||
out = prev;
|
||||
}
|
||||
self.pos_prev = Some(out);
|
||||
self.speed_prev = if speed_hat.is_finite() {
|
||||
speed_hat
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
out
|
||||
}
|
||||
|
||||
/// Filtert einen 2D-Punkt `pos` und Druck `pressure` zum Zeitstempel `t`.
|
||||
pub fn filter_point(&mut self, pos: glam::Vec2, pressure: f32, t: f64) -> (glam::Vec2, f32) {
|
||||
let (x, y, p) = self.filter(pos.x, pos.y, pressure, t);
|
||||
@@ -279,6 +334,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_loops_do_not_shrink() {
|
||||
// Kreis mit 0,8 mm Radius in 0,1 s: die Filterung in der Ebene folgt ihm
|
||||
let mut filter = OneEuroFilter::default();
|
||||
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||
let mut radii = Vec::new();
|
||||
for i in 0..=(period * hz) as usize * 2 {
|
||||
let t = i as f64 / hz;
|
||||
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||
let p = glam::Vec2::new(500.0 + r * a.sin(), 500.0 - r * a.cos());
|
||||
let (out, _) = filter.filter_point(p, 0.5, t);
|
||||
if t > period {
|
||||
radii.push((out - glam::Vec2::new(500.0, 500.0)).length());
|
||||
}
|
||||
}
|
||||
let mean = radii.iter().sum::<f32>() / radii.len() as f32;
|
||||
assert!(mean > r * 0.9, "Schleife geschrumpft: {mean}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_high_frequency_jitter() {
|
||||
let mut filter = OneEuroFilter::default();
|
||||
@@ -286,7 +360,8 @@ mod tests {
|
||||
let base_x = 100.0_f32;
|
||||
let base_y = 50.0_f32;
|
||||
let base_p = 0.5_f32;
|
||||
let jitter_amplitude = 2.0_f32;
|
||||
// Realistisches Digitizer-Rauschen (±0,05 mm) bei ruhendem Stift
|
||||
let jitter_amplitude = 0.5_f32;
|
||||
|
||||
let num_samples = 120;
|
||||
let dt = 1.0 / 120.0; // 120 Hz Abtastrate
|
||||
@@ -331,9 +406,10 @@ mod tests {
|
||||
.sum::<f32>()
|
||||
/ count;
|
||||
|
||||
// Der 1€-Filter muss das hochfrequente Jitter massiv dämpfen
|
||||
// Der 1€-Filter muss das hochfrequente Jitter deutlich dämpfen (der fertige Strich
|
||||
// wird zusätzlich symmetrisch geglättet)
|
||||
assert!(
|
||||
filtered_variance < raw_variance * 0.05,
|
||||
filtered_variance < raw_variance * 0.2,
|
||||
"Gefilterte Varianz ({filtered_variance}) muss deutlich kleiner sein als Raw-Varianz ({raw_variance})"
|
||||
);
|
||||
}
|
||||
|
||||
+378
-8
@@ -59,6 +59,11 @@ pub enum RecognizedShape {
|
||||
x_axis_end: Vec2,
|
||||
y_axis_end: Vec2,
|
||||
with_arrows: bool,
|
||||
/// Achsenkreuz: Enden der x- und der y-Achse auf der anderen Seite des Ursprungs
|
||||
/// (Pfeilspitzen an allen vier Enden). Fehlt es, ist es das L-förmige
|
||||
/// Koordinatensystem; ältere Versionen ignorieren das Feld und zeigen das L.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
negative_ends: Option<[Vec2; 2]>,
|
||||
},
|
||||
Triangle {
|
||||
/// 3 Eckpunkte in umlaufender Reihenfolge.
|
||||
@@ -105,6 +110,10 @@ impl RecognizedShape {
|
||||
Self::Arrow { double: false, .. } => "Pfeil",
|
||||
Self::Arrow { double: true, .. } => "Doppelpfeil",
|
||||
Self::Rectangle { .. } => "Rechteck",
|
||||
Self::CoordinateSystem {
|
||||
negative_ends: Some(_),
|
||||
..
|
||||
} => "Achsenkreuz",
|
||||
Self::CoordinateSystem { .. } => "Koordinatensystem",
|
||||
Self::Triangle { .. } => "Dreieck",
|
||||
Self::Rhombus { .. } => "Raute (Rhombus)",
|
||||
@@ -146,11 +155,15 @@ impl RecognizedShape {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends,
|
||||
..
|
||||
} => {
|
||||
*origin += delta;
|
||||
*x_axis_end += delta;
|
||||
*y_axis_end += delta;
|
||||
for end in negative_ends.iter_mut().flatten() {
|
||||
*end += delta;
|
||||
}
|
||||
}
|
||||
Self::Polygon { vertices } => {
|
||||
for v in vertices {
|
||||
@@ -203,11 +216,15 @@ impl RecognizedShape {
|
||||
origin: o,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends,
|
||||
..
|
||||
} => {
|
||||
*o = rot(*o);
|
||||
*x_axis_end = rot(*x_axis_end);
|
||||
*y_axis_end = rot(*y_axis_end);
|
||||
for end in negative_ends.iter_mut().flatten() {
|
||||
*end = rot(*end);
|
||||
}
|
||||
}
|
||||
Self::Polygon { vertices } => {
|
||||
for v in vertices {
|
||||
@@ -267,11 +284,15 @@ impl RecognizedShape {
|
||||
origin: o,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends,
|
||||
..
|
||||
} => {
|
||||
*o = sc(*o);
|
||||
*x_axis_end = sc(*x_axis_end);
|
||||
*y_axis_end = sc(*y_axis_end);
|
||||
for end in negative_ends.iter_mut().flatten() {
|
||||
*end = sc(*end);
|
||||
}
|
||||
}
|
||||
Self::Polygon { vertices } => {
|
||||
for v in vertices {
|
||||
@@ -1394,6 +1415,87 @@ pub fn detect_coordinate_system(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
x_axis_end: x_end,
|
||||
y_axis_end: y_end,
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Achsenkreuz in einem Strich („+“, z. B. waagerecht hin und zurück, dann senkrecht): Alle
|
||||
/// Teilstücke liegen auf einer waagerechten und einer senkrechten Linie, und beide reichen
|
||||
/// auf beiden Seiten deutlich über den Schnittpunkt hinaus. Ergebnis wie beim
|
||||
/// Koordinatensystem achsenparallel, mit Pfeilspitzen an allen vier Enden.
|
||||
pub fn detect_axis_cross(points: &[Vec2]) -> Option<RecognizedShape> {
|
||||
if points.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let aabb = Aabb::from_points(points);
|
||||
let diagonal = (aabb.max - aabb.min).length();
|
||||
if diagonal < 60.0 {
|
||||
return None;
|
||||
}
|
||||
let simplified = ramer_douglas_peucker(points, (diagonal * 0.05).clamp(8.0, 40.0));
|
||||
|
||||
// Teilstücke nach Richtung: waagerecht oder senkrecht (höchstens 25° schief); kurze
|
||||
// Stücke (Umkehrpunkte) zählen nicht, schräge lange Stücke schließen ein Kreuz aus
|
||||
let max_slope = 25.0_f32.to_radians().tan();
|
||||
let (mut horizontal, mut vertical) = ((0.0, 0.0), (0.0, 0.0));
|
||||
for w in simplified.windows(2) {
|
||||
let d = w[1] - w[0];
|
||||
let len = d.length();
|
||||
if len < diagonal * 0.08 {
|
||||
continue;
|
||||
}
|
||||
let mid = (w[0] + w[1]) * 0.5;
|
||||
if d.y.abs() <= d.x.abs() * max_slope {
|
||||
horizontal = (horizontal.0 + mid.y * len, horizontal.1 + len);
|
||||
} else if d.x.abs() <= d.y.abs() * max_slope {
|
||||
vertical = (vertical.0 + mid.x * len, vertical.1 + len);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if horizontal.1 == 0.0 || vertical.1 == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let origin = Vec2::new(vertical.0 / vertical.1, horizontal.0 / horizontal.1);
|
||||
|
||||
// Jeder Punkt liegt auf einer der beiden Linien
|
||||
let tolerance = (diagonal * 0.08).max(10.0);
|
||||
let (mut x_min, mut x_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
let (mut y_min, mut y_max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
for p in points {
|
||||
let on_horizontal = (p.y - origin.y).abs() <= tolerance;
|
||||
let on_vertical = (p.x - origin.x).abs() <= tolerance;
|
||||
if !on_horizontal && !on_vertical {
|
||||
return None;
|
||||
}
|
||||
if on_horizontal {
|
||||
x_min = x_min.min(p.x);
|
||||
x_max = x_max.max(p.x);
|
||||
}
|
||||
if on_vertical {
|
||||
y_min = y_min.min(p.y);
|
||||
y_max = y_max.max(p.y);
|
||||
}
|
||||
}
|
||||
|
||||
// Vier Arme: jeder mindestens ein Fünftel seiner Achse und deutlich länger als die Toleranz
|
||||
let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2 && arm >= tolerance * 1.5;
|
||||
let (width, height) = (x_max - x_min, y_max - y_min);
|
||||
if !(arm_ok(origin.x - x_min, width)
|
||||
&& arm_ok(x_max - origin.x, width)
|
||||
&& arm_ok(origin.y - y_min, height)
|
||||
&& arm_ok(y_max - origin.y, height))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// x nach rechts, y nach oben (auf dem Blatt: kleineres y)
|
||||
Some(RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end: Vec2::new(x_max, origin.y),
|
||||
y_axis_end: Vec2::new(origin.x, y_min),
|
||||
with_arrows: true,
|
||||
negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1423,7 +1525,10 @@ pub fn detect_shape(points: &[StylusSample]) -> Option<RecognizedShape> {
|
||||
// Kandidaten in Prioritätsreihenfolge; 3. offene Formen (Koordinatensystem, Gerade) zuletzt.
|
||||
// Übernommen wird der erste, der den Strich wirklich beschreibt. Passt keiner, bleibt der
|
||||
// Strich unverändert (vorher wurde fast jede geschlossene Kritzelei zu einer Form).
|
||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 6] = [
|
||||
let candidates: [&dyn Fn() -> Option<RecognizedShape>; 7] = [
|
||||
// Das Achsenkreuz zuerst: Seine Prüfung ist streng, und ein hin und her gezeichnetes
|
||||
// „+“ könnte sonst als Vieleck durchgehen
|
||||
&|| detect_axis_cross(&pts),
|
||||
&|| {
|
||||
if closed_fits_well {
|
||||
None
|
||||
@@ -1908,6 +2013,51 @@ pub fn shape_to_mesh(
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: Some([x_axis_start, y_axis_start]),
|
||||
} => {
|
||||
// Achsenkreuz: zwei durchgehende Achsen, Pfeilspitzen an allen vier Enden
|
||||
let mut vertices = Vec::new();
|
||||
for (neg, pos) in [(*x_axis_start, *x_axis_end), (*y_axis_start, *y_axis_end)] {
|
||||
let dir = (pos - neg).normalize_or_zero();
|
||||
let pos_head = arrow_head(pos, dir, half_w, (pos - *origin).length());
|
||||
let neg_head = arrow_head(neg, -dir, half_w, (neg - *origin).length());
|
||||
let (from, to) = if *with_arrows {
|
||||
(neg_head.0, pos_head.0)
|
||||
} else {
|
||||
(neg, pos)
|
||||
};
|
||||
push_band(&mut vertices, from, to, half_w, color);
|
||||
if *with_arrows {
|
||||
for (tip, (_, left, right)) in [(pos, pos_head), (neg, neg_head)] {
|
||||
push_triangle(&mut vertices, tip, left, right, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Abtastpunkte als Linienzug ohne Diagonalen (jeder Arm vom Ursprung aus)
|
||||
let samples = [
|
||||
*x_axis_start,
|
||||
*origin,
|
||||
*x_axis_end,
|
||||
*origin,
|
||||
*y_axis_end,
|
||||
*origin,
|
||||
*y_axis_start,
|
||||
]
|
||||
.map(|position| StylusSample {
|
||||
position,
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.to_vec();
|
||||
(samples, vertices)
|
||||
}
|
||||
|
||||
RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
negative_ends: None,
|
||||
} => {
|
||||
let mut vertices = Vec::new();
|
||||
let mut samples = Vec::new();
|
||||
@@ -1924,16 +2074,21 @@ pub fn shape_to_mesh(
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
// Beide Achsen beginnen um die halbe Linienbreite hinter dem Ursprung, damit die
|
||||
// äußere Ecke geschlossen ist (sonst bleibt dort eine quadratische Kerbe)
|
||||
let y_start = *origin - y_dir * half_w;
|
||||
let x_start = *origin - x_dir * half_w;
|
||||
|
||||
// 1. Y-Achse (vom Ursprung zur Spitze)
|
||||
let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w;
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x + y_norm.x,
|
||||
origin.y + y_norm.y,
|
||||
y_start.x + y_norm.x,
|
||||
y_start.y + y_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x - y_norm.x,
|
||||
origin.y - y_norm.y,
|
||||
y_start.x - y_norm.x,
|
||||
y_start.y - y_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
@@ -1950,15 +2105,15 @@ pub fn shape_to_mesh(
|
||||
// Degenerierte Dreiecke für den Übergang zur X-Achse
|
||||
let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w;
|
||||
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(x_start.x + x_norm.x, x_start.y + x_norm.y, color);
|
||||
vertices.push(last_v);
|
||||
vertices.push(next_v);
|
||||
|
||||
// 2. X-Achse
|
||||
vertices.push(next_v);
|
||||
vertices.push(InkVertex::new(
|
||||
origin.x - x_norm.x,
|
||||
origin.y - x_norm.y,
|
||||
x_start.x - x_norm.x,
|
||||
x_start.y - x_norm.y,
|
||||
color,
|
||||
));
|
||||
vertices.push(InkVertex::new(
|
||||
@@ -2007,6 +2162,40 @@ pub fn shape_to_mesh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Hängt ein gerades Band der halben Breite `half_w` von `a` nach `b` an einen Triangle-Strip
|
||||
/// an (über degenerierte Dreiecke, falls er schon Vertizes hat).
|
||||
fn push_band(vertices: &mut Vec<InkVertex>, a: Vec2, b: Vec2, half_w: f32, color: [f32; 4]) {
|
||||
let dir = (b - a).normalize_or_zero();
|
||||
let norm = Vec2::new(-dir.y, dir.x) * half_w;
|
||||
let first = InkVertex::new(a.x + norm.x, a.y + norm.y, color);
|
||||
if let Some(&last) = vertices.last() {
|
||||
vertices.push(last);
|
||||
vertices.push(first);
|
||||
}
|
||||
vertices.push(first);
|
||||
vertices.push(InkVertex::new(a.x - norm.x, a.y - norm.y, color));
|
||||
vertices.push(InkVertex::new(b.x + norm.x, b.y + norm.y, color));
|
||||
vertices.push(InkVertex::new(b.x - norm.x, b.y - norm.y, color));
|
||||
}
|
||||
|
||||
/// Hängt ein Dreieck (Pfeilspitze) an einen Triangle-Strip an.
|
||||
fn push_triangle(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
tip: Vec2,
|
||||
left: Vec2,
|
||||
right: Vec2,
|
||||
color: [f32; 4],
|
||||
) {
|
||||
let tip_v = InkVertex::new(tip.x, tip.y, color);
|
||||
if let Some(&last) = vertices.last() {
|
||||
vertices.push(last);
|
||||
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));
|
||||
}
|
||||
|
||||
/// Erzeugt einen neuen `Stroke` aus einer erkannten Form.
|
||||
pub fn shape_to_stroke(
|
||||
shape: RecognizedShape,
|
||||
@@ -2195,6 +2384,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coordinate_system_corner_is_closed() {
|
||||
// Die Achsen treffen sich ohne Kerbe: Die äußere Ecke des Ursprungs ist ein Eckpunkt
|
||||
// der Achsenbänder (Issue #15)
|
||||
let origin = Vec2::new(300.0, 900.0);
|
||||
let shape = RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end: Vec2::new(900.0, 900.0),
|
||||
y_axis_end: Vec2::new(300.0, 300.0),
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
};
|
||||
let width = 30.0;
|
||||
let (_, vertices) = shape_to_mesh(&shape, width, [0.0, 0.0, 0.0, 1.0]);
|
||||
let corner = origin + Vec2::new(-width / 2.0, width / 2.0);
|
||||
let nearest = vertices
|
||||
.iter()
|
||||
.map(|v| (Vec2::from(v.position) - corner).length())
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
assert!(
|
||||
nearest < 1e-3,
|
||||
"äußere Ecke fehlt, nächster Punkt {nearest}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_triangle() {
|
||||
let mut points = Vec::new();
|
||||
@@ -2450,12 +2664,167 @@ mod tests {
|
||||
Some(RecognizedShape::Triangle { .. }) => "Dreieck",
|
||||
Some(RecognizedShape::Rhombus { .. }) => "Raute",
|
||||
Some(RecognizedShape::Polygon { .. }) => "Polygon",
|
||||
Some(RecognizedShape::CoordinateSystem {
|
||||
negative_ends: Some(_),
|
||||
..
|
||||
}) => "Achsenkreuz",
|
||||
Some(RecognizedShape::CoordinateSystem { .. }) => "Koordinatensystem",
|
||||
Some(RecognizedShape::Octahedron { .. }) => "Oktaeder",
|
||||
None => "nichts",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_axis_cross_in_one_stroke() {
|
||||
let c = Vec2::new(800.0, 900.0);
|
||||
let v = |x: f32, y: f32| c + Vec2::new(x, y);
|
||||
// Verschiedene Zeichenreihenfolgen eines „+“ in einem Zug
|
||||
let crosses: [(&str, Vec<Vec2>); 4] = [
|
||||
(
|
||||
"waagerecht hin, zurück zur Mitte, senkrecht",
|
||||
vec![
|
||||
v(-200.0, 0.0),
|
||||
v(200.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, -150.0),
|
||||
v(0.0, 150.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"senkrecht zuerst",
|
||||
vec![
|
||||
v(0.0, -180.0),
|
||||
v(0.0, 180.0),
|
||||
v(0.0, 0.0),
|
||||
v(-220.0, 0.0),
|
||||
v(220.0, 0.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Arme ungleich lang",
|
||||
vec![
|
||||
v(-100.0, 0.0),
|
||||
v(320.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, 250.0),
|
||||
v(0.0, -120.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Mitte zuerst, alle Arme hin und zurück",
|
||||
vec![
|
||||
v(0.0, 0.0),
|
||||
v(200.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, -200.0),
|
||||
v(0.0, 0.0),
|
||||
v(-200.0, 0.0),
|
||||
v(0.0, 0.0),
|
||||
v(0.0, 200.0),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (seed, (name, outline)) in crosses.iter().enumerate() {
|
||||
let samples = freehand(outline, false, seed as f32);
|
||||
let shape = detect_shape(&samples);
|
||||
assert_eq!(kind(&shape), "Achsenkreuz", "{name}");
|
||||
let Some(RecognizedShape::CoordinateSystem {
|
||||
origin,
|
||||
x_axis_end,
|
||||
y_axis_end,
|
||||
negative_ends: Some([x_start, y_start]),
|
||||
with_arrows: true,
|
||||
}) = shape
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!((origin - c).length() < 8.0, "{name}: Ursprung {origin}");
|
||||
// Achsenparallel, x nach rechts, y nach oben
|
||||
assert_eq!(x_axis_end.y, origin.y);
|
||||
assert_eq!(x_start.y, origin.y);
|
||||
assert_eq!(y_axis_end.x, origin.x);
|
||||
assert!(x_axis_end.x > origin.x && x_start.x < origin.x, "{name}");
|
||||
assert!(y_axis_end.y < origin.y && y_start.y > origin.y, "{name}");
|
||||
}
|
||||
|
||||
// Keine Kreuze: L-Form, T-Form, einzelne Linie, Rechteck
|
||||
let not_crosses: [(&str, Vec<Vec2>, bool); 4] = [
|
||||
("L", vec![v(0.0, -200.0), v(0.0, 0.0), v(250.0, 0.0)], false),
|
||||
(
|
||||
"T",
|
||||
vec![v(-200.0, 0.0), v(200.0, 0.0), v(0.0, 0.0), v(0.0, 200.0)],
|
||||
false,
|
||||
),
|
||||
("Linie", vec![v(-200.0, 0.0), v(200.0, 0.0)], false),
|
||||
(
|
||||
"Rechteck",
|
||||
vec![
|
||||
v(-200.0, -100.0),
|
||||
v(200.0, -100.0),
|
||||
v(200.0, 100.0),
|
||||
v(-200.0, 100.0),
|
||||
],
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (seed, (name, outline, closed)) in not_crosses.iter().enumerate() {
|
||||
let samples = freehand(outline, *closed, seed as f32 + 10.0);
|
||||
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||
assert!(
|
||||
detect_axis_cross(&points).is_none(),
|
||||
"{name} ist kein Kreuz"
|
||||
);
|
||||
assert_ne!(kind(&detect_shape(&samples)), "Achsenkreuz", "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_axis_cross_mesh_and_transforms() {
|
||||
let mut shape = RecognizedShape::CoordinateSystem {
|
||||
origin: Vec2::new(500.0, 500.0),
|
||||
x_axis_end: Vec2::new(800.0, 500.0),
|
||||
y_axis_end: Vec2::new(500.0, 300.0),
|
||||
with_arrows: true,
|
||||
negative_ends: Some([Vec2::new(300.0, 500.0), Vec2::new(500.0, 700.0)]),
|
||||
};
|
||||
assert_eq!(shape.name(), "Achsenkreuz");
|
||||
let (samples, vertices) = shape_to_mesh(&shape, 10.0, [0.0, 0.0, 0.0, 1.0]);
|
||||
// Alle vier Pfeilspitzen sind Eckpunkte des Netzes
|
||||
for tip in [
|
||||
(800.0, 500.0),
|
||||
(500.0, 300.0),
|
||||
(300.0, 500.0),
|
||||
(500.0, 700.0),
|
||||
] {
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.any(|v| (Vec2::from(v.position) - Vec2::from(tip)).length() < 1e-3),
|
||||
"Spitze {tip:?} fehlt"
|
||||
);
|
||||
}
|
||||
// Die Form passt zu sich selbst gezeichnet (Abtastpunkte ohne Diagonalen)
|
||||
let points: Vec<Vec2> = samples.iter().map(|s| s.position).collect();
|
||||
assert!(shape_fit_error(&points, &shape).unwrap() < 1e-3);
|
||||
// Verschieben bewegt auch die negativen Enden
|
||||
shape.translate(Vec2::new(10.0, 20.0));
|
||||
let RecognizedShape::CoordinateSystem {
|
||||
negative_ends: Some([x_start, y_start]),
|
||||
..
|
||||
} = shape
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(x_start, Vec2::new(310.0, 520.0));
|
||||
assert_eq!(y_start, Vec2::new(510.0, 720.0));
|
||||
// Ältere Dateien ohne das Feld bleiben L-Koordinatensysteme; neue Dateien enthalten es
|
||||
let json = serde_json::to_string(&shape).unwrap();
|
||||
assert!(json.contains("negative_ends"));
|
||||
let old = r#"{"CoordinateSystem":{"origin":[0.0,0.0],"x_axis_end":[1.0,0.0],"y_axis_end":[0.0,-1.0],"with_arrows":true}}"#;
|
||||
let parsed: RecognizedShape = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(parsed.name(), "Koordinatensystem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_shape_freehand_classification() {
|
||||
let c = Vec2::new(1000.0, 1400.0);
|
||||
@@ -2855,6 +3224,7 @@ mod tests {
|
||||
x_axis_end: x_end,
|
||||
y_axis_end: y_end,
|
||||
with_arrows: true,
|
||||
negative_ends: None,
|
||||
};
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
use crate::document::Stroke;
|
||||
|
||||
/// Eine rückgängig machbare Benutzeraktion auf einer Seite.
|
||||
// Der Verlauf hält nur wenige hundert Einträge; die Größe des Ersetzen-Eintrags (zwei Striche)
|
||||
// fällt nicht ins Gewicht, eine Box würde nur die Aufrufe umständlicher machen
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HistoryAction {
|
||||
/// Ein Strich wurde neu gezeichnet.
|
||||
@@ -18,6 +21,9 @@ pub enum HistoryAction {
|
||||
before: Vec<Stroke>,
|
||||
after: Vec<Stroke>,
|
||||
},
|
||||
/// Platz geschaffen: Inhalt mehrerer Seiten wurde verschoben. Rückgängig macht das das
|
||||
/// Notizbuch (`Notebook::undo`), nicht die einzelne Seite.
|
||||
SpaceShift(Box<crate::document::SpaceShift>),
|
||||
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
|
||||
SplitStrokes {
|
||||
removed: Vec<Stroke>,
|
||||
@@ -136,6 +142,10 @@ impl HistoryStack {
|
||||
self.redo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
// Betrifft mehrere Seiten: Das Notizbuch macht es rückgängig, hier nur umbuchen
|
||||
HistoryAction::SpaceShift(shift) => {
|
||||
self.redo_stack.push(HistoryAction::SpaceShift(shift));
|
||||
}
|
||||
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
|
||||
strokes.extend(removed.iter().cloned());
|
||||
@@ -187,6 +197,9 @@ impl HistoryStack {
|
||||
self.undo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
HistoryAction::SpaceShift(shift) => {
|
||||
self.undo_stack.push(HistoryAction::SpaceShift(shift));
|
||||
}
|
||||
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
|
||||
strokes.extend(added.iter().cloned());
|
||||
|
||||
+8
-3
@@ -102,6 +102,7 @@ impl ImageLayer {
|
||||
shader: &wgpu::ShaderModule,
|
||||
camera_layout: &wgpu::BindGroupLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
sample_count: u32,
|
||||
) -> Self {
|
||||
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Image Texture Bind Group Layout"),
|
||||
@@ -150,8 +151,11 @@ impl ImageLayer {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
depth_stencil: Some(crate::canvas::passive_stencil()),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: sample_count,
|
||||
..Default::default()
|
||||
},
|
||||
multiview: None,
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
@@ -359,7 +363,8 @@ impl ImageLayer {
|
||||
mip_level_count: levels.len() as u32,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
// sRGB-Werte ohne Umrechnung: gezeichnet wird in sRGB (siehe canvas::render_format)
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod image_layer;
|
||||
pub mod library;
|
||||
pub mod osk;
|
||||
pub mod page_export;
|
||||
pub mod pdf_doc;
|
||||
pub mod pdf_merge;
|
||||
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
//! Ordner für Notizbücher (`<Basisordner>/library.json`).
|
||||
//!
|
||||
//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen
|
||||
//! `<Basisordner>/<id>/`, `library.json` hält nur fest, welcher Ordner welches Notizbuch
|
||||
//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder
|
||||
//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts.
|
||||
//!
|
||||
//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`);
|
||||
//! die oberste Ebene ist der leere Pfad.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// Oberste Ebene.
|
||||
pub const ROOT: &str = "";
|
||||
|
||||
/// Längster erlaubter Ordnername (Zeichen).
|
||||
const MAX_NAME_CHARS: usize = 80;
|
||||
|
||||
/// Ordnerbaum und Zuordnung der Notizbücher.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Library {
|
||||
/// Alle Ordner als Pfade
|
||||
pub folders: BTreeSet<String>,
|
||||
/// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene
|
||||
pub notebooks: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Letzter Teil eines Ordnerpfads (der angezeigte Name).
|
||||
pub fn folder_name(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene).
|
||||
pub fn parent_folder(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent)
|
||||
}
|
||||
|
||||
/// Pfad des Unterordners `name` in `parent`.
|
||||
fn join(parent: &str, name: &str) -> String {
|
||||
if parent.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` liegt in `folder` oder ist `folder` selbst.
|
||||
fn is_within(path: &str, folder: &str) -> bool {
|
||||
path == folder
|
||||
|| path
|
||||
.strip_prefix(folder)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
}
|
||||
|
||||
/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen.
|
||||
pub fn validate_name(name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Der Ordnername ist leer".to_string());
|
||||
}
|
||||
if name.contains('/') {
|
||||
return Err("Ordnernamen dürfen kein „/“ enthalten".to_string());
|
||||
}
|
||||
if name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(format!(
|
||||
"Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)"
|
||||
));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// Ort von `library.json` im Basisordner der Notizbücher.
|
||||
pub fn file_path(base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("library.json")
|
||||
}
|
||||
|
||||
/// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner.
|
||||
pub fn load(base_dir: &Path) -> Self {
|
||||
let path = Self::file_path(base_dir);
|
||||
let mut library: Self = match std::fs::read_to_string(&path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.",
|
||||
path.display()
|
||||
);
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
};
|
||||
library.repair();
|
||||
library
|
||||
}
|
||||
|
||||
/// Speichert die Ordner atomar.
|
||||
pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> {
|
||||
storage::atomic_write_json(&Self::file_path(base_dir), self)
|
||||
}
|
||||
|
||||
/// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen
|
||||
/// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren.
|
||||
fn repair(&mut self) {
|
||||
let valid = |path: &str| {
|
||||
!path.is_empty()
|
||||
&& path
|
||||
.split('/')
|
||||
.all(|name| validate_name(name).as_deref() == Ok(name))
|
||||
};
|
||||
self.folders.retain(|f| valid(f));
|
||||
self.notebooks.retain(|_, f| f.is_empty() || valid(f));
|
||||
let used: Vec<String> = self
|
||||
.folders
|
||||
.iter()
|
||||
.chain(self.notebooks.values())
|
||||
.cloned()
|
||||
.collect();
|
||||
for mut folder in used {
|
||||
while !folder.is_empty() {
|
||||
let parent = parent_folder(&folder).to_string();
|
||||
self.folders.insert(folder);
|
||||
folder = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordner, in dem das Notizbuch liegt.
|
||||
pub fn folder_of(&self, notebook_id: &str) -> &str {
|
||||
self.notebooks.get(notebook_id).map_or(ROOT, String::as_str)
|
||||
}
|
||||
|
||||
/// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert).
|
||||
pub fn subfolders(&self, parent: &str) -> Vec<&str> {
|
||||
let mut list: Vec<&str> = self
|
||||
.folders
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|f| parent_folder(f) == parent)
|
||||
.collect();
|
||||
list.sort_by_key(|f| folder_name(f).to_lowercase());
|
||||
list
|
||||
}
|
||||
|
||||
/// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name).
|
||||
pub fn all_folders(&self) -> Vec<&str> {
|
||||
let mut out = Vec::with_capacity(self.folders.len());
|
||||
let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect();
|
||||
while let Some(folder) = stack.pop() {
|
||||
out.push(folder);
|
||||
stack.extend(self.subfolders(folder).into_iter().rev());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück.
|
||||
pub fn create_folder(&mut self, parent: &str, name: &str) -> Result<String, String> {
|
||||
let name = validate_name(name)?;
|
||||
if !parent.is_empty() && !self.folders.contains(parent) {
|
||||
return Err("Der übergeordnete Ordner existiert nicht mehr".to_string());
|
||||
}
|
||||
let path = join(parent, &name);
|
||||
if !self.folders.insert(path.clone()) {
|
||||
return Err(format!("Den Ordner „{name}“ gibt es hier schon"));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad
|
||||
/// zurück.
|
||||
pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result<String, String> {
|
||||
let new_name = validate_name(new_name)?;
|
||||
if !self.folders.contains(path) {
|
||||
return Err("Den Ordner gibt es nicht mehr".to_string());
|
||||
}
|
||||
let new_path = join(parent_folder(path), &new_name);
|
||||
if new_path == path {
|
||||
return Ok(new_path);
|
||||
}
|
||||
if self.folders.contains(&new_path) {
|
||||
return Err(format!("Den Ordner „{new_name}“ gibt es hier schon"));
|
||||
}
|
||||
self.move_contents(path, &new_path);
|
||||
Ok(new_path)
|
||||
}
|
||||
|
||||
/// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben
|
||||
/// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch.
|
||||
pub fn delete_folder(&mut self, path: &str) {
|
||||
if path.is_empty() || !self.folders.remove(path) {
|
||||
return;
|
||||
}
|
||||
let parent = parent_folder(path).to_string();
|
||||
let prefix = format!("{path}/");
|
||||
let moved: Vec<String> = self
|
||||
.folders
|
||||
.iter()
|
||||
.filter(|f| f.starts_with(&prefix))
|
||||
.cloned()
|
||||
.collect();
|
||||
for folder in moved {
|
||||
self.folders.remove(&folder);
|
||||
self.folders.insert(join(&parent, &folder[prefix.len()..]));
|
||||
}
|
||||
for folder in self.notebooks.values_mut() {
|
||||
if folder == path {
|
||||
*folder = parent.clone();
|
||||
} else if let Some(rest) = folder.strip_prefix(&prefix) {
|
||||
*folder = join(&parent, rest);
|
||||
}
|
||||
}
|
||||
self.notebooks.retain(|_, f| !f.is_empty());
|
||||
}
|
||||
|
||||
/// Verschiebt einen Ordner samt Inhalt nach `new_path`.
|
||||
fn move_contents(&mut self, path: &str, new_path: &str) {
|
||||
let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]);
|
||||
let moved: Vec<String> = self
|
||||
.folders
|
||||
.iter()
|
||||
.filter(|f| is_within(f, path))
|
||||
.cloned()
|
||||
.collect();
|
||||
for folder in moved {
|
||||
self.folders.remove(&folder);
|
||||
self.folders.insert(renamed(&folder));
|
||||
}
|
||||
for folder in self.notebooks.values_mut() {
|
||||
if is_within(folder, path) {
|
||||
*folder = renamed(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und
|
||||
/// gibt seinen neuen Pfad zurück. Nicht in sich selbst oder einen eigenen Unterordner.
|
||||
pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result<String, String> {
|
||||
if !self.folders.contains(path) {
|
||||
return Err("Den Ordner gibt es nicht mehr".to_string());
|
||||
}
|
||||
if !new_parent.is_empty() && !self.folders.contains(new_parent) {
|
||||
return Err("Den Zielordner gibt es nicht mehr".to_string());
|
||||
}
|
||||
if is_within(new_parent, path) {
|
||||
return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string());
|
||||
}
|
||||
let name = folder_name(path);
|
||||
let new_path = join(new_parent, name);
|
||||
if new_path == path {
|
||||
return Ok(new_path);
|
||||
}
|
||||
if self.folders.contains(&new_path) {
|
||||
return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“"));
|
||||
}
|
||||
self.move_contents(path, &new_path);
|
||||
Ok(new_path)
|
||||
}
|
||||
|
||||
/// Mögliche Ziele für das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und
|
||||
/// seinen Unterordnern (die oberste Ebene ist immer ein Ziel).
|
||||
pub fn folder_move_targets(&self, path: &str) -> Vec<&str> {
|
||||
self.all_folders()
|
||||
.into_iter()
|
||||
.filter(|f| !is_within(f, path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]).
|
||||
pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> {
|
||||
if folder.is_empty() {
|
||||
self.notebooks.remove(notebook_id);
|
||||
return Ok(());
|
||||
}
|
||||
if !self.folders.contains(folder) {
|
||||
return Err("Den Ordner gibt es nicht mehr".to_string());
|
||||
}
|
||||
self.notebooks
|
||||
.insert(notebook_id.to_string(), folder.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen.
|
||||
pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator<Item = &'a str>) -> usize {
|
||||
ids.into_iter()
|
||||
.filter(|id| is_within(self.folder_of(id), folder))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn library() -> Library {
|
||||
let mut lib = Library::default();
|
||||
let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap();
|
||||
let mathe = lib.create_folder(&sem3, "Mathe").unwrap();
|
||||
lib.create_folder(&sem3, "Physik").unwrap();
|
||||
lib.move_notebook("analysis", &mathe).unwrap();
|
||||
lib.move_notebook("uebung", &sem3).unwrap();
|
||||
lib
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paths_and_names() {
|
||||
assert_eq!(folder_name("3. Semester/Mathe"), "Mathe");
|
||||
assert_eq!(folder_name("Mathe"), "Mathe");
|
||||
assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester");
|
||||
assert_eq!(parent_folder("Mathe"), ROOT);
|
||||
assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe");
|
||||
assert!(validate_name(" ").is_err());
|
||||
assert!(validate_name("a/b").is_err());
|
||||
assert!(validate_name(&"x".repeat(81)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_list_folders() {
|
||||
let mut lib = library();
|
||||
assert_eq!(lib.subfolders(ROOT), ["3. Semester"]);
|
||||
assert_eq!(
|
||||
lib.subfolders("3. Semester"),
|
||||
["3. Semester/Mathe", "3. Semester/Physik"]
|
||||
);
|
||||
assert_eq!(
|
||||
lib.all_folders(),
|
||||
["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"]
|
||||
);
|
||||
assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe");
|
||||
assert_eq!(lib.folder_of("unbekannt"), ROOT);
|
||||
// Doppelte Namen und fehlende Eltern werden abgelehnt
|
||||
assert!(lib.create_folder("3. Semester", " Mathe ").is_err());
|
||||
assert!(lib.create_folder("Gibt es nicht", "X").is_err());
|
||||
// Gleicher Name in einem anderen Ordner ist erlaubt
|
||||
lib.create_folder(ROOT, "4. Semester").unwrap();
|
||||
assert!(lib.create_folder("4. Semester", "Mathe").is_ok());
|
||||
assert_eq!(
|
||||
lib.count_within("3. Semester", ["analysis", "uebung", "lose"]),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_folder_keeps_contents() {
|
||||
let mut lib = library();
|
||||
let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap();
|
||||
assert_eq!(new, "WS 25-26");
|
||||
assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe");
|
||||
assert_eq!(lib.folder_of("uebung"), "WS 25-26");
|
||||
assert!(lib.folders.contains("WS 25-26/Physik"));
|
||||
assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester")));
|
||||
// Ein Ordner mit ähnlichem Namen bleibt unberührt
|
||||
let mut lib = library();
|
||||
lib.create_folder(ROOT, "3. Semester alt").unwrap();
|
||||
lib.rename_folder("3. Semester", "Drei").unwrap();
|
||||
assert!(lib.folders.contains("3. Semester alt"));
|
||||
// Kollision
|
||||
assert!(lib.rename_folder("Drei", "3. Semester alt").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_folder_moves_contents_up() {
|
||||
let mut lib = library();
|
||||
lib.delete_folder("3. Semester");
|
||||
assert_eq!(lib.folder_of("analysis"), "Mathe");
|
||||
assert_eq!(lib.folder_of("uebung"), ROOT);
|
||||
assert!(!lib.notebooks.contains_key("uebung"));
|
||||
assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_folder() {
|
||||
let mut lib = library();
|
||||
lib.create_folder(ROOT, "Mathe-Sammlung").unwrap();
|
||||
// Ordner samt Inhalt in einen anderen Ordner schieben
|
||||
let new = lib
|
||||
.move_folder("3. Semester/Mathe", "Mathe-Sammlung")
|
||||
.unwrap();
|
||||
assert_eq!(new, "Mathe-Sammlung/Mathe");
|
||||
assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe");
|
||||
assert!(!lib.folders.contains("3. Semester/Mathe"));
|
||||
// Auf die oberste Ebene und zurück
|
||||
assert_eq!(
|
||||
lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(),
|
||||
"Mathe"
|
||||
);
|
||||
assert_eq!(
|
||||
lib.move_folder("Mathe", "3. Semester").unwrap(),
|
||||
"3. Semester/Mathe"
|
||||
);
|
||||
// Nicht in sich selbst oder in einen eigenen Unterordner
|
||||
assert!(lib.move_folder("3. Semester", "3. Semester").is_err());
|
||||
assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err());
|
||||
assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]);
|
||||
// Namenskollision am Ziel
|
||||
lib.create_folder("Mathe-Sammlung", "Physik").unwrap();
|
||||
assert!(lib
|
||||
.move_folder("3. Semester/Physik", "Mathe-Sammlung")
|
||||
.is_err());
|
||||
assert_eq!(lib.folder_of("uebung"), "3. Semester");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_notebook() {
|
||||
let mut lib = library();
|
||||
lib.move_notebook("uebung", "3. Semester/Physik").unwrap();
|
||||
assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik");
|
||||
lib.move_notebook("uebung", ROOT).unwrap();
|
||||
assert_eq!(lib.folder_of("uebung"), ROOT);
|
||||
assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_save_and_repair() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Fehlende Datei: keine Ordner
|
||||
assert_eq!(Library::load(dir.path()), Library::default());
|
||||
|
||||
let lib = library();
|
||||
lib.save(dir.path()).unwrap();
|
||||
assert_eq!(Library::load(dir.path()), lib);
|
||||
|
||||
// Beschädigt: alles auf oberster Ebene statt Absturz
|
||||
std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap();
|
||||
assert_eq!(Library::load(dir.path()), Library::default());
|
||||
|
||||
// Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg
|
||||
std::fs::write(
|
||||
Library::file_path(dir.path()),
|
||||
br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let lib = Library::load(dir.path());
|
||||
assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]);
|
||||
assert_eq!(lib.folder_of("x"), "E/F");
|
||||
assert_eq!(lib.folder_of("y"), ROOT);
|
||||
}
|
||||
}
|
||||
+629
-91
File diff suppressed because it is too large
Load Diff
+294
@@ -0,0 +1,294 @@
|
||||
//! Bildschirmtastatur unter Wayland anfordern.
|
||||
//!
|
||||
//! GNOME (mutter) blendet die Bildschirmtastatur nicht schon beim ersten `enable` eines
|
||||
//! Texteingabefelds (`zwp_text_input_v3`) ein, sondern erst, wenn ein bereits aktives Feld
|
||||
//! `enable` erneut meldet – so macht es GTK beim Antippen. winit meldet `enable` nur einmal,
|
||||
//! sobald egui ein Textfeld fokussiert. Daher schickt ink-notes über ein eigenes
|
||||
//! text-input-Objekt auf derselben Wayland-Verbindung ein weiteres `enable`. Die getippten
|
||||
//! Zeichen kommen weiterhin über winit an.
|
||||
//!
|
||||
//! Die Rücktaste der GNOME-Bildschirmtastatur löscht über `delete_surrounding_text` im zuvor
|
||||
//! gemeldeten Umgebungstext; ohne Umgebungstext passiert nichts, und winit wertet das Löschen
|
||||
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
|
||||
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
|
||||
|
||||
use std::ffi::c_void;
|
||||
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::{wl_registry, wl_seat},
|
||||
Connection, Dispatch, EventQueue, QueueHandle,
|
||||
};
|
||||
use wayland_protocols::wp::text_input::zv3::client::{
|
||||
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||
zwp_text_input_v3::{self, ZwpTextInputV3},
|
||||
};
|
||||
|
||||
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
|
||||
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
|
||||
const SURROUNDING_PLACEHOLDER: &str = " ";
|
||||
|
||||
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
|
||||
///
|
||||
/// Die Bildschirmtastatur schickt jedes Zeichen als fertigen IME-Text, ohne vorher eine Vorschau
|
||||
/// zu melden. egui 0.27 fügt solche Texte nur ein, solange die Schreibmarke dort steht, wo die
|
||||
/// letzte Vorschau begann – also nur das erste Zeichen. `composing` merkt sich über Frames hinweg,
|
||||
/// ob gerade eine Vorschau läuft; deren Abschluss bleibt bei egui.
|
||||
pub fn plain_ime_commits_to_text(events: &mut [egui::Event], composing: &mut bool) {
|
||||
for event in events {
|
||||
match event {
|
||||
egui::Event::CompositionStart => *composing = true,
|
||||
egui::Event::CompositionEnd(text) => {
|
||||
if !*composing && !text.is_empty() && !text.contains(['\n', '\r']) {
|
||||
*event = egui::Event::Text(std::mem::take(text));
|
||||
}
|
||||
*composing = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tastendrücke für egui, die `back` Zeichen vor und `forward` Zeichen nach der Schreibmarke
|
||||
/// löschen
|
||||
pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
|
||||
let presses = std::iter::repeat_n(egui::Key::Backspace, back as usize)
|
||||
.chain(std::iter::repeat_n(egui::Key::Delete, forward as usize));
|
||||
presses
|
||||
.flat_map(|key| {
|
||||
[true, false].map(|pressed| egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
|
||||
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
|
||||
/// zugeklappt wurde).
|
||||
pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool {
|
||||
active && (!was_active || pressed)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
seat: Option<wl_seat::WlSeat>,
|
||||
manager: Option<ZwpTextInputManagerV3>,
|
||||
text_input: Option<ZwpTextInputV3>,
|
||||
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
|
||||
delete_back: u32,
|
||||
delete_forward: u32,
|
||||
}
|
||||
|
||||
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
|
||||
pub struct KeyboardRequester {
|
||||
conn: Connection,
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl KeyboardRequester {
|
||||
/// Hängt sich an die Wayland-Verbindung des Fensters.
|
||||
///
|
||||
/// # Safety
|
||||
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
|
||||
/// der `KeyboardRequester` lebt.
|
||||
pub unsafe fn new(wl_display: *mut c_void) -> Self {
|
||||
// Safety: an den Aufrufer weitergereicht
|
||||
let backend =
|
||||
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
let queue = conn.new_event_queue();
|
||||
conn.display().get_registry(&queue.handle(), ());
|
||||
let _ = conn.flush();
|
||||
Self {
|
||||
conn,
|
||||
queue,
|
||||
state: State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
|
||||
pub fn pump(&mut self) {
|
||||
let _ = self.queue.dispatch_pending(&mut self.state);
|
||||
if self.state.text_input.is_none() {
|
||||
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
|
||||
self.state.text_input =
|
||||
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
|
||||
/// winit kommen (also nach `handle_platform_output`).
|
||||
pub fn request(&mut self) {
|
||||
self.pump();
|
||||
if let Some(text_input) = &self.state.text_input {
|
||||
text_input.enable();
|
||||
self.send_surrounding_placeholder();
|
||||
}
|
||||
}
|
||||
|
||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||
/// aufrufen und an die egui-Eingabe anhängen)
|
||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||
self.pump();
|
||||
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
|
||||
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
|
||||
let back = std::mem::take(&mut self.state.delete_back);
|
||||
let forward = std::mem::take(&mut self.state.delete_forward);
|
||||
deletion_key_events(back, forward)
|
||||
}
|
||||
|
||||
fn send_surrounding_placeholder(&self) {
|
||||
if let Some(text_input) = &self.state.text_input {
|
||||
let len = SURROUNDING_PLACEHOLDER.len() as i32;
|
||||
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
|
||||
text_input.commit();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name, interface, ..
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"wl_seat" if state.seat.is_none() => {
|
||||
state.seat = Some(registry.bind(name, 1, qh, ()));
|
||||
}
|
||||
"zwp_text_input_manager_v3" if state.manager.is_none() => {
|
||||
state.manager = Some(registry.bind(name, 1, qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||
delegate_noop!(State: ZwpTextInputManagerV3);
|
||||
impl Dispatch<ZwpTextInputV3, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ZwpTextInputV3,
|
||||
event: zwp_text_input_v3::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// Getippter Text kommt über winit; hier nur das Löschen
|
||||
if let zwp_text_input_v3::Event::DeleteSurroundingText {
|
||||
before_length,
|
||||
after_length,
|
||||
} = event
|
||||
{
|
||||
state.delete_back += before_length;
|
||||
state.delete_forward += after_length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requests_when_text_field_gets_focus() {
|
||||
assert!(should_request(false, true, false));
|
||||
assert!(should_request(false, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_again_when_focused_field_is_tapped() {
|
||||
assert!(should_request(true, true, true));
|
||||
assert!(!should_request(true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_without_preedit_become_text() {
|
||||
let mut composing = false;
|
||||
let mut events = vec![
|
||||
egui::Event::CompositionEnd("h".into()),
|
||||
egui::Event::CompositionEnd("i".into()),
|
||||
];
|
||||
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![egui::Event::Text("h".into()), egui::Event::Text("i".into())]
|
||||
);
|
||||
assert!(!composing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_after_preedit_stay_with_egui() {
|
||||
let mut composing = false;
|
||||
let mut events = vec![
|
||||
egui::Event::CompositionStart,
|
||||
egui::Event::CompositionUpdate("ni".into()),
|
||||
];
|
||||
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||
assert!(composing);
|
||||
// Abschluss erst im nächsten Frame
|
||||
let mut events = vec![egui::Event::CompositionEnd("你".into())];
|
||||
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||
assert_eq!(events, vec![egui::Event::CompositionEnd("你".into())]);
|
||||
assert!(!composing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_and_empty_commits_are_left_alone() {
|
||||
let mut composing = false;
|
||||
let mut events = vec![
|
||||
egui::Event::CompositionEnd("\n".into()),
|
||||
egui::Event::CompositionEnd(String::new()),
|
||||
];
|
||||
let before = events.clone();
|
||||
plain_ime_commits_to_text(&mut events, &mut composing);
|
||||
assert_eq!(events, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletions_become_key_presses() {
|
||||
let events = deletion_key_events(2, 1);
|
||||
let keys: Vec<_> = events
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
egui::Event::Key { key, pressed, .. } => (*key, *pressed),
|
||||
other => panic!("unerwartet: {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
(egui::Key::Backspace, true),
|
||||
(egui::Key::Backspace, false),
|
||||
(egui::Key::Backspace, true),
|
||||
(egui::Key::Backspace, false),
|
||||
(egui::Key::Delete, true),
|
||||
(egui::Key::Delete, false),
|
||||
]
|
||||
);
|
||||
assert!(deletion_key_events(0, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_request_without_focused_text_field() {
|
||||
assert!(!should_request(false, false, true));
|
||||
assert!(!should_request(true, false, true));
|
||||
}
|
||||
}
|
||||
+139
-16
@@ -12,6 +12,7 @@ use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||
|
||||
use crate::assets::{AssetStore, RgbaImage};
|
||||
use crate::background::PatternColor;
|
||||
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
|
||||
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
||||
|
||||
@@ -30,9 +31,13 @@ pub enum ImageBackground {
|
||||
}
|
||||
|
||||
/// Erzeugt ein einseitiges PDF aus `page`.
|
||||
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
fn single_page_pdf(
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
pattern: PatternColor,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets)
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets, pattern)
|
||||
}
|
||||
|
||||
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
|
||||
@@ -91,11 +96,14 @@ pub fn page_png(
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
background: ImageBackground,
|
||||
pattern: PatternColor,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let image = match background {
|
||||
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
|
||||
ImageBackground::Paper => {
|
||||
rasterize(single_page_pdf(page, assets, pattern)?, PAGE_PNG_DPI, false)?
|
||||
}
|
||||
ImageBackground::Transparent => rasterize(
|
||||
single_page_pdf(&content_only(page), assets)?,
|
||||
single_page_pdf(&content_only(page), assets, pattern)?,
|
||||
PAGE_PNG_DPI,
|
||||
true,
|
||||
)?,
|
||||
@@ -104,9 +112,13 @@ pub fn page_png(
|
||||
}
|
||||
|
||||
/// 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:?}"))?;
|
||||
pub fn page_svg(
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
pattern: PatternColor,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
|
||||
.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,
|
||||
@@ -133,7 +145,11 @@ pub fn selection_image(
|
||||
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)?;
|
||||
let full = rasterize(
|
||||
single_page_pdf(&selected, assets, PatternColor::default())?,
|
||||
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);
|
||||
@@ -190,12 +206,22 @@ mod tests {
|
||||
#[test]
|
||||
fn test_page_png_paper_and_transparent() {
|
||||
let page = page_with_line();
|
||||
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
|
||||
let paper = decode(
|
||||
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).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());
|
||||
let clear = decode(
|
||||
&page_png(
|
||||
&page,
|
||||
None,
|
||||
ImageBackground::Transparent,
|
||||
PatternColor::default(),
|
||||
)
|
||||
.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;
|
||||
@@ -207,9 +233,74 @@ mod tests {
|
||||
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_highlighter_is_translucent_in_export() {
|
||||
// Schwarze Linie, darüber ein gelber Textmarker, der hin und zurück läuft
|
||||
let mut page = page_with_line();
|
||||
page.strokes[0].color = [0.0, 0.0, 0.0, 1.0];
|
||||
for v in &mut page.strokes[0].vertices {
|
||||
v.color = [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
let color = [1.0, 0.95, 0.1, 0.35];
|
||||
let samples: Vec<StylusSample> = (0..=40)
|
||||
.map(|i| {
|
||||
let x = if i <= 20 { i } else { 40 - i } as f32;
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(500.0 + x * 20.0, 1000.0 + (i as f32 * 0.5)),
|
||||
pressure: 0.6,
|
||||
timestamp: i as f64 * 0.01,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||
&samples,
|
||||
color,
|
||||
60.0,
|
||||
Default::default(),
|
||||
true,
|
||||
);
|
||||
let mut stroke = Stroke::new(8, samples, vertices, color);
|
||||
stroke.width = 60.0;
|
||||
stroke.tool = crate::document::StrokeTool::Highlighter;
|
||||
page.strokes.push(stroke);
|
||||
|
||||
let png = decode(
|
||||
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
|
||||
);
|
||||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||
let at = |x: f32, y: f32| {
|
||||
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
|
||||
.0
|
||||
};
|
||||
// Die schwarze Linie bleibt unter dem Textmarker sichtbar
|
||||
let line = at(700.0, 1000.0);
|
||||
assert!(line[0] < 110 && line[1] < 110, "Linie verdeckt: {line:?}");
|
||||
// Neben der Linie: einmal gelb getönt, auch wo der Strich sich selbst überlappt
|
||||
let tint = at(700.0, 1020.0);
|
||||
assert!(tint[2] < 200 && tint[0] > 200, "kein Textmarker: {tint:?}");
|
||||
let expected_blue = 255.0 * (1.0 - 0.35) + 0.1 * 255.0 * 0.35;
|
||||
assert!(
|
||||
(tint[2] as f32 - expected_blue).abs() < 25.0,
|
||||
"Textmarker mehrfach gedeckt: {tint:?}, erwartet Blau ≈ {expected_blue}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_uses_pattern_color() {
|
||||
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
|
||||
let page = Page::new(1, PageBackground::Grid5mm);
|
||||
let darkest = |pattern| {
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Paper, pattern).unwrap());
|
||||
png.pixels().map(|p| p.0[1]).min().unwrap()
|
||||
};
|
||||
let light = darkest(PatternColor::Light);
|
||||
let strong = darkest(PatternColor::Strong);
|
||||
assert!(strong + 60 < light, "hell {light}, kräftig {strong}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_svg_contains_ink() {
|
||||
let svg = page_svg(&page_with_line(), None).unwrap();
|
||||
let svg = page_svg(&page_with_line(), None, PatternColor::default()).unwrap();
|
||||
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
|
||||
assert!(svg.contains("<path"));
|
||||
assert!(svg.trim_end().ends_with("</svg>"));
|
||||
@@ -253,7 +344,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_exported_text_matches_screen_rendering() {
|
||||
let (page, text) = page_with_text();
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let png = decode(
|
||||
&page_png(
|
||||
&page,
|
||||
None,
|
||||
ImageBackground::Transparent,
|
||||
PatternColor::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||
let exported = ink_bounds(png.width(), png.as_raw());
|
||||
|
||||
@@ -278,12 +377,28 @@ mod tests {
|
||||
#[test]
|
||||
fn test_exported_text_follows_rotation() {
|
||||
let (mut page, _) = page_with_text();
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let png = decode(
|
||||
&page_png(
|
||||
&page,
|
||||
None,
|
||||
ImageBackground::Transparent,
|
||||
PatternColor::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let straight = ink_bounds(png.width(), png.as_raw());
|
||||
|
||||
let center = page.strokes[0].aabb.min;
|
||||
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let png = decode(
|
||||
&page_png(
|
||||
&page,
|
||||
None,
|
||||
ImageBackground::Transparent,
|
||||
PatternColor::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let rotated = ink_bounds(png.width(), png.as_raw());
|
||||
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
|
||||
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
|
||||
@@ -302,7 +417,15 @@ mod tests {
|
||||
let mut page = page_with_line();
|
||||
page.size = glam::Vec2::new(2970.0, 1671.0);
|
||||
page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500
|
||||
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||
let png = decode(
|
||||
&page_png(
|
||||
&page,
|
||||
None,
|
||||
ImageBackground::Transparent,
|
||||
PatternColor::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let ratio = png.width() as f32 / png.height() as f32;
|
||||
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
|
||||
let px_per_unit = png.width() as f32 / 2970.0;
|
||||
@@ -312,7 +435,7 @@ mod tests {
|
||||
};
|
||||
assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts");
|
||||
assert!(at(2600.0, 1300.0)[3] == 0);
|
||||
let svg = page_svg(&page, None).unwrap();
|
||||
let svg = page_svg(&page, None, PatternColor::default()).unwrap();
|
||||
assert!(svg.contains("<path"));
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,62 @@ pub fn display_backend_from_env() -> DisplayBackend {
|
||||
)
|
||||
}
|
||||
|
||||
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
||||
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
||||
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
||||
/// Frame), läge sie ganz woanders: Ein Tippen auf einen Knopf ginge daneben, ein Strich begänne
|
||||
/// mit einer Linie von der alten Stelle. Hier folgt jedes `Down` auf die nächste `Pose`, spätestens
|
||||
/// vor dem nächsten anderen Ereignis.
|
||||
pub fn down_after_pose<T>(
|
||||
events: impl IntoIterator<Item = T>,
|
||||
is_down: impl Fn(&T) -> bool,
|
||||
is_pose: impl Fn(&T) -> bool,
|
||||
) -> Vec<T> {
|
||||
let mut ordered = Vec::new();
|
||||
let mut deferred = None;
|
||||
for event in events {
|
||||
if is_down(&event) {
|
||||
if let Some(down) = deferred.replace(event) {
|
||||
ordered.push(down);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let pose = is_pose(&event);
|
||||
if !pose {
|
||||
ordered.extend(deferred.take());
|
||||
}
|
||||
ordered.push(event);
|
||||
if pose {
|
||||
ordered.extend(deferred.take());
|
||||
}
|
||||
}
|
||||
ordered.extend(deferred);
|
||||
ordered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_down_follows_pose_of_its_frame() {
|
||||
let order = |events: &str| {
|
||||
down_after_pose(events.chars(), |c| *c == 'D', |c| *c == 'P')
|
||||
.into_iter()
|
||||
.collect::<String>()
|
||||
};
|
||||
// In, Down, Pose, Button, Frame -> Down erst mit der neuen Position
|
||||
assert_eq!(order("IDPBF"), "IPDBF");
|
||||
// Down ohne Pose im Frame: vor dem Frame-Ende
|
||||
assert_eq!(order("DF"), "DF");
|
||||
assert_eq!(order("DBF"), "DBF");
|
||||
// Am Ende ohne weiteres Ereignis
|
||||
assert_eq!(order("PD"), "PD");
|
||||
// Andere Ereignisse bleiben unverändert
|
||||
assert_eq!(order("PFPFUF"), "PFPFUF");
|
||||
assert_eq!(order("IDPFPFUF"), "IPDFPFUF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_choose_display_backend() {
|
||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||
|
||||
+20
-2
@@ -203,13 +203,19 @@ impl SelectionState {
|
||||
pub fn paste_from_clipboard(
|
||||
&mut self,
|
||||
page: &mut Page,
|
||||
at: Option<Vec2>,
|
||||
next_id_gen: &mut impl FnMut() -> u64,
|
||||
) -> Vec<u64> {
|
||||
if self.clipboard.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let offset = Vec2::new(30.0, 30.0); // ca. 3 mm Versatz nach rechts-unten
|
||||
// Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst
|
||||
// ca. 3 mm nach rechts unten versetzt
|
||||
let offset = match (at, Page::selection_aabb(&self.clipboard)) {
|
||||
(Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0,
|
||||
_ => Vec2::new(30.0, 30.0),
|
||||
};
|
||||
let mut new_strokes = Vec::with_capacity(self.clipboard.len());
|
||||
let mut new_ids = Vec::with_capacity(self.clipboard.len());
|
||||
|
||||
@@ -457,7 +463,7 @@ mod tests {
|
||||
|
||||
// Paste
|
||||
let mut next_id = 100;
|
||||
let new_ids = sel.paste_from_clipboard(&mut page, &mut || {
|
||||
let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || {
|
||||
next_id += 1;
|
||||
next_id
|
||||
});
|
||||
@@ -465,6 +471,18 @@ mod tests {
|
||||
assert_eq!(page.strokes.len(), 3);
|
||||
assert_eq!(sel.selected_ids, new_ids);
|
||||
|
||||
// An einer Stelle einfügen: die Mitte der Kopie liegt dort
|
||||
let target = Vec2::new(1000.0, 1500.0);
|
||||
let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || {
|
||||
next_id += 1;
|
||||
next_id
|
||||
});
|
||||
let aabb = sel.get_selection_aabb(&page).unwrap();
|
||||
assert_eq!(sel.selected_ids, placed);
|
||||
assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01);
|
||||
sel.delete_selected(&mut page);
|
||||
sel.selected_ids = new_ids.clone();
|
||||
|
||||
// Delete
|
||||
let deleted = sel.delete_selected(&mut page);
|
||||
assert_eq!(deleted.len(), 1);
|
||||
|
||||
+220
-1
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::background::PaperTheme;
|
||||
use crate::background::{PaperTheme, PatternColor};
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||
@@ -73,6 +73,130 @@ impl Default for PressureCurve {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stift-Vorlage: Farbe und Stärke eines der Stifte in der Werkzeugleiste. Ändert man Farbe
|
||||
/// oder Stärke, während ein Stift gewählt ist, merkt sich die Vorlage die neuen Werte.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PenPreset {
|
||||
pub color: [f32; 4],
|
||||
pub width_mm: f32,
|
||||
}
|
||||
|
||||
impl PenPreset {
|
||||
pub const WIDTH_RANGE: std::ops::RangeInclusive<f32> = 0.15..=5.0;
|
||||
|
||||
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei; Stifte sind
|
||||
/// immer deckend.
|
||||
pub fn sanitized(&self, fallback: PenPreset) -> Self {
|
||||
let color = sanitized_color(self.color, [0.0, 0.0, 0.0, 1.0]);
|
||||
let width_mm = if self.width_mm.is_finite() {
|
||||
self.width_mm
|
||||
.clamp(*Self::WIDTH_RANGE.start(), *Self::WIDTH_RANGE.end())
|
||||
} else {
|
||||
fallback.width_mm
|
||||
};
|
||||
Self { color, width_mm }
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzahl der Stifte in der Werkzeugleiste.
|
||||
pub const PEN_PRESET_COUNT: usize = 4;
|
||||
|
||||
/// Standardstifte: Schwarz, Blau und Rot fein, Grün etwas kräftiger.
|
||||
pub const DEFAULT_PEN_PRESETS: [PenPreset; PEN_PRESET_COUNT] = [
|
||||
PenPreset {
|
||||
color: [0.12, 0.12, 0.14, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.15, 0.35, 0.85, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.85, 0.20, 0.20, 1.0],
|
||||
width_mm: 0.5,
|
||||
},
|
||||
PenPreset {
|
||||
color: [0.18, 0.65, 0.35, 1.0],
|
||||
width_mm: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
/// Farben des Textmarkers in der Werkzeugleiste (halbtransparent).
|
||||
pub const DEFAULT_HIGHLIGHTER_COLORS: [[f32; 4]; 4] = [
|
||||
[1.0, 0.95, 0.1, 0.35],
|
||||
[0.2, 0.9, 0.3, 0.35],
|
||||
[0.25, 0.7, 1.0, 0.35],
|
||||
[1.0, 0.3, 0.65, 0.35],
|
||||
];
|
||||
|
||||
/// Farben des Text-Werkzeugs in der Werkzeugleiste.
|
||||
pub const DEFAULT_TEXT_COLORS: [[f32; 4]; 5] = [
|
||||
[0.12, 0.12, 0.14, 1.0],
|
||||
[0.15, 0.35, 0.85, 1.0],
|
||||
[0.85, 0.20, 0.20, 1.0],
|
||||
[0.18, 0.65, 0.35, 1.0],
|
||||
[0.45, 0.45, 0.48, 1.0],
|
||||
];
|
||||
|
||||
/// Begrenzt eine Farbe aus der Einstellungsdatei auf 0–1; die Deckkraft kommt immer aus
|
||||
/// `fallback` (Stift und Text deckend, Textmarker halbtransparent).
|
||||
fn sanitized_color(color: [f32; 4], fallback: [f32; 4]) -> [f32; 4] {
|
||||
let mut out = fallback;
|
||||
if color.iter().all(|c| c.is_finite()) {
|
||||
for (o, c) in out.iter_mut().zip(color).take(3) {
|
||||
*o = c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Was eine Taste am Stift bewirkt, solange sie gedrückt ist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum StylusAction {
|
||||
/// Radieren (Standard, wie bisher)
|
||||
#[default]
|
||||
Erase,
|
||||
/// Auswählen und verschieben
|
||||
Select,
|
||||
/// Mit dem Textmarker schreiben
|
||||
Highlighter,
|
||||
/// Mit dem gewählten Stift schreiben (z. B. während der Textmarker aktiv ist)
|
||||
Pen,
|
||||
/// Taste nicht verwenden
|
||||
Nothing,
|
||||
}
|
||||
|
||||
impl StylusAction {
|
||||
pub const ALL: [StylusAction; 5] = [
|
||||
StylusAction::Erase,
|
||||
StylusAction::Select,
|
||||
StylusAction::Highlighter,
|
||||
StylusAction::Pen,
|
||||
StylusAction::Nothing,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StylusAction::Erase => "Radieren",
|
||||
StylusAction::Select => "Auswählen",
|
||||
StylusAction::Highlighter => "Textmarker",
|
||||
StylusAction::Pen => "Stift",
|
||||
StylusAction::Nothing => "Nichts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Taste am Stift: Kennung (aus der Tablet-Schnittstelle, beim ersten Drücken gelernt)
|
||||
/// und ihre Belegung.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StylusButton {
|
||||
pub id: u64,
|
||||
pub action: StylusAction,
|
||||
}
|
||||
|
||||
/// Höchstens so viele Stifttasten werden gemerkt.
|
||||
pub const MAX_STYLUS_BUTTONS: usize = 4;
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -82,10 +206,22 @@ pub struct UiSettings {
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Farbe von Karos, Linien und Punkten (Anzeige und Export)
|
||||
pub pattern_color: PatternColor,
|
||||
/// Druckkennlinie des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
|
||||
pub continuous_scroll: bool,
|
||||
/// Stifte der Werkzeugleiste und der gewählte davon
|
||||
pub pen_presets: [PenPreset; PEN_PRESET_COUNT],
|
||||
pub active_pen: usize,
|
||||
/// Eigene Farben von Textmarker und Text-Werkzeug und die gewählte davon
|
||||
pub highlighter_colors: [[f32; 4]; 4],
|
||||
pub active_highlighter: usize,
|
||||
pub text_colors: [[f32; 4]; 5],
|
||||
pub active_text_color: usize,
|
||||
/// Tasten am Stift in der Reihenfolge, in der sie zuerst gedrückt wurden
|
||||
pub stylus_buttons: Vec<StylusButton>,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -94,8 +230,16 @@ impl Default for UiSettings {
|
||||
sidebar_side: SidebarSide::default(),
|
||||
paper_theme: PaperTheme::default(),
|
||||
adapt_ink_colors: true,
|
||||
pattern_color: PatternColor::default(),
|
||||
pressure_curve: PressureCurve::default(),
|
||||
continuous_scroll: true,
|
||||
pen_presets: DEFAULT_PEN_PRESETS,
|
||||
active_pen: 0,
|
||||
highlighter_colors: DEFAULT_HIGHLIGHTER_COLORS,
|
||||
active_highlighter: 0,
|
||||
text_colors: DEFAULT_TEXT_COLORS,
|
||||
active_text_color: 0,
|
||||
stylus_buttons: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +267,27 @@ impl UiSettings {
|
||||
Err(_) => Self::default(),
|
||||
};
|
||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||
for (preset, fallback) in settings.pen_presets.iter_mut().zip(DEFAULT_PEN_PRESETS) {
|
||||
*preset = preset.sanitized(fallback);
|
||||
}
|
||||
settings.active_pen = settings.active_pen.min(PEN_PRESET_COUNT - 1);
|
||||
for (color, fallback) in settings
|
||||
.highlighter_colors
|
||||
.iter_mut()
|
||||
.zip(DEFAULT_HIGHLIGHTER_COLORS)
|
||||
{
|
||||
*color = sanitized_color(*color, fallback);
|
||||
}
|
||||
settings.active_highlighter = settings
|
||||
.active_highlighter
|
||||
.min(settings.highlighter_colors.len() - 1);
|
||||
for (color, fallback) in settings.text_colors.iter_mut().zip(DEFAULT_TEXT_COLORS) {
|
||||
*color = sanitized_color(*color, fallback);
|
||||
}
|
||||
settings.active_text_color = settings
|
||||
.active_text_color
|
||||
.min(settings.text_colors.len() - 1);
|
||||
settings.stylus_buttons.truncate(MAX_STYLUS_BUTTONS);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -148,11 +313,31 @@ mod tests {
|
||||
sidebar_side: SidebarSide::Right,
|
||||
paper_theme: PaperTheme::Black,
|
||||
adapt_ink_colors: false,
|
||||
pattern_color: PatternColor::Strong,
|
||||
pressure_curve: PressureCurve {
|
||||
exponent: 1.8,
|
||||
threshold: 0.1,
|
||||
},
|
||||
continuous_scroll: false,
|
||||
pen_presets: [PenPreset {
|
||||
color: [0.5, 0.2, 0.7, 1.0],
|
||||
width_mm: 1.5,
|
||||
}; PEN_PRESET_COUNT],
|
||||
active_pen: 2,
|
||||
highlighter_colors: [[0.9, 0.5, 0.1, 0.35]; 4],
|
||||
active_highlighter: 3,
|
||||
text_colors: [[0.3, 0.1, 0.6, 1.0]; 5],
|
||||
active_text_color: 4,
|
||||
stylus_buttons: vec![
|
||||
StylusButton {
|
||||
id: 17,
|
||||
action: StylusAction::Select,
|
||||
},
|
||||
StylusButton {
|
||||
id: 42,
|
||||
action: StylusAction::Nothing,
|
||||
},
|
||||
],
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
@@ -178,6 +363,7 @@ mod tests {
|
||||
assert!(settings.adapt_ink_colors);
|
||||
assert_eq!(settings.pressure_curve, PressureCurve::default());
|
||||
assert!(settings.continuous_scroll);
|
||||
assert_eq!(settings.pattern_color, PatternColor::Light);
|
||||
|
||||
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
|
||||
std::fs::write(
|
||||
@@ -188,6 +374,39 @@ mod tests {
|
||||
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||
assert_eq!(curve.exponent, 3.0);
|
||||
assert_eq!(curve.threshold, 0.0);
|
||||
|
||||
// Ohne Stifte: die Standardstifte; unsinnige Stifte werden begrenzt
|
||||
assert_eq!(settings.pen_presets, DEFAULT_PEN_PRESETS);
|
||||
assert_eq!(settings.active_pen, 0);
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"active_pen": 9, "pen_presets": [
|
||||
{"color": [2.0, -1.0, 0.5, 0.2], "width_mm": 99.0},
|
||||
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 0.0},
|
||||
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0},
|
||||
{"color": [0.1, 0.2, 0.3, 1.0], "width_mm": 1.0}
|
||||
]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.active_pen, PEN_PRESET_COUNT - 1);
|
||||
assert_eq!(settings.pen_presets[0].color, [1.0, 0.0, 0.5, 1.0]);
|
||||
assert_eq!(settings.pen_presets[0].width_mm, 5.0);
|
||||
assert_eq!(settings.pen_presets[1].width_mm, 0.15);
|
||||
|
||||
// Eigene Farben: Textmarker bleibt halbtransparent, Text deckend
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"highlighter_colors": [[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
|
||||
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]], "active_highlighter": 7,
|
||||
"text_colors": [[5.0, 0.2, 0.3, 0.1], [0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0],
|
||||
[0.1, 0.2, 0.3, 1.0], [0.1, 0.2, 0.3, 1.0]]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.highlighter_colors[0], [0.1, 0.2, 0.3, 0.35]);
|
||||
assert_eq!(settings.active_highlighter, 3);
|
||||
assert_eq!(settings.text_colors[0], [1.0, 0.2, 0.3, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+117
-10
@@ -30,25 +30,50 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Linearer Farbwert (Papier, Muster) -> sRGB: Gezeichnet wird ohne sRGB-Umrechnung der GPU
|
||||
// (siehe `render_format` in canvas.rs), gemischt wird also wie im PDF in sRGB
|
||||
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let low = c * 12.92;
|
||||
let high = 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
|
||||
return select(high, low, c <= vec3<f32>(0.0031308));
|
||||
}
|
||||
|
||||
// Papier: Farbe linear
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
return vec4<f32>(linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0))), in.color.a);
|
||||
}
|
||||
|
||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||
// Striche: Tintenfarben sind sRGB-Werte wie im Export und in der Oberfläche. 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;
|
||||
var rgb = clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
if (camera.ink_params.x >= 0.5) {
|
||||
let max_c = max(max(rgb.r, rgb.g), rgb.b);
|
||||
let min_c = min(min(rgb.r, rgb.g), rgb.b);
|
||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||
rgb = min(rgb + vec3<f32>(lift), vec3<f32>(1.0));
|
||||
}
|
||||
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);
|
||||
return vec4<f32>(rgb, in.color.a);
|
||||
}
|
||||
|
||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
||||
// Bildschirmfüllendes Dreieck ohne Farbe (setzt den Stencil-Puffer zurück)
|
||||
@vertex
|
||||
fn vs_fullscreen(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
|
||||
let x = f32((index << 1u) & 2u) * 2.0 - 1.0;
|
||||
let y = f32(index & 2u) * 2.0 - 1.0;
|
||||
return vec4<f32>(x, y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_none() -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
|
||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte (Texturen enthalten sRGB-Werte
|
||||
// und werden unverändert übernommen)
|
||||
struct ImageVertexInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@@ -77,3 +102,85 @@ fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||
return textureSample(image_texture, image_sampler, in.uv);
|
||||
}
|
||||
|
||||
// Karo, Linien und Punkte: pro Pixel aus der Seitenkoordinate berechnet, damit die Abstände
|
||||
// in jedem Zoom gleichmäßig bleiben. Linien und Punkte sind mindestens ein Pixel breit und
|
||||
// kantengeglättet; werden die Abstände sehr klein, blendet das Muster aus statt zu flimmern.
|
||||
struct PatternInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// Art (1 Karo, 2 Linien, 3 Punkte), Abstand, Linienstärke, Punktgröße
|
||||
@location(2) params: vec4<f32>,
|
||||
// Seitenbreite, Seitenhöhe, Rand oben/unten bei Linien
|
||||
@location(3) bounds: vec4<f32>,
|
||||
};
|
||||
|
||||
struct PatternOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) page: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) params: vec4<f32>,
|
||||
@location(3) bounds: vec4<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_pattern(in: PatternInput) -> PatternOutput {
|
||||
var out: PatternOutput;
|
||||
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||
out.page = in.position;
|
||||
out.color = in.color;
|
||||
out.params = in.params;
|
||||
out.bounds = in.bounds;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Deckung einer Linie bei `line` für die Koordinate `c` (`upp`: Seiteneinheiten je Pixel).
|
||||
fn line_coverage(c: f32, line: f32, half_width: f32, upp: f32) -> f32 {
|
||||
let half_px = max(half_width / upp, 0.5);
|
||||
let d = abs(c - line) / upp;
|
||||
return clamp(half_px + 0.5 - d, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_pattern(in: PatternOutput) -> @location(0) vec4<f32> {
|
||||
let p = in.page;
|
||||
let kind = in.params.x;
|
||||
let spacing = in.params.y;
|
||||
let upp = max(max(fwidth(p.x), fwidth(p.y)), 1e-4);
|
||||
let size = in.bounds.xy;
|
||||
// Unter 3 Pixel Abstand ausblenden (ganz herausgezoomt)
|
||||
let fade = clamp((spacing / upp - 3.0) / 3.0, 0.0, 1.0);
|
||||
|
||||
var coverage = 0.0;
|
||||
if (kind < 1.5) {
|
||||
// Karo: senkrechte und waagerechte Linien bei k · Abstand, nicht am Blattrand
|
||||
let k = round(p / spacing);
|
||||
let line = k * spacing;
|
||||
if (k.x >= 1.0 && line.x < size.x - 0.01) {
|
||||
coverage = max(coverage, line_coverage(p.x, line.x, in.params.z * 0.5, upp));
|
||||
}
|
||||
if (k.y >= 1.0 && line.y < size.y - 0.01) {
|
||||
coverage = max(coverage, line_coverage(p.y, line.y, in.params.z * 0.5, upp));
|
||||
}
|
||||
} else if (kind < 2.5) {
|
||||
// Linien: waagerecht ab dem oberen Rand bis zum unteren Rand
|
||||
let margin = in.bounds.z;
|
||||
let k = round((p.y - margin) / spacing);
|
||||
let line = margin + k * spacing;
|
||||
if (k >= 0.0 && line <= size.y - margin + 0.01) {
|
||||
coverage = line_coverage(p.y, line, in.params.z * 0.5, upp);
|
||||
}
|
||||
} else {
|
||||
// Punkte: an den Kreuzungen des Karos
|
||||
let k = round(p / spacing);
|
||||
let point = k * spacing;
|
||||
if (all(k >= vec2<f32>(1.0)) && point.x < size.x - 0.01 && point.y < size.y - 0.01) {
|
||||
let radius_px = max(in.params.w * 0.5 / upp, 0.6);
|
||||
let d = length(p - point) / upp;
|
||||
coverage = clamp(radius_px + 0.5 - d, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let rgb = linear_to_srgb(clamp(in.color.rgb, vec3<f32>(0.0), vec3<f32>(1.0)));
|
||||
return vec4<f32>(rgb, in.color.a * coverage * fade);
|
||||
}
|
||||
|
||||
+66
-7
@@ -17,6 +17,10 @@ pub const RDP_TOLERANCE_UNITS: f32 = 0.75;
|
||||
|
||||
/// Schwellenwert für Ecken-Erkennung (Richtungsänderung > 65 Grad).
|
||||
pub const CORNER_ANGLE_THRESHOLD_DEG: f32 = 65.0;
|
||||
/// Ecken nur zwischen Abschnitten, die nach der Ausdünnung mindestens so lang sind (0,6 mm).
|
||||
/// Kleine Schleifen („o“, „a“, „e“) bestehen aus kurzen Abschnitten mit großem Knickwinkel;
|
||||
/// würden sie als Ecken getrennt, würden sie zu spitzen Zacken statt Rundungen.
|
||||
pub const CORNER_MIN_SEGMENT_UNITS: f32 = 6.0;
|
||||
|
||||
/// GPU-Vertex für Triangle-Strips (Memory-Layout kompatibel mit GPU-Buffern).
|
||||
#[repr(C)]
|
||||
@@ -270,7 +274,7 @@ pub fn generate_triangle_strip(
|
||||
// Einzelner Punkt (Stift-Tippen / Dot): Erzeuge kreisförmiges Pad
|
||||
let pt = control_points[0];
|
||||
let radius = calculate_radius(pt.pressure, config.min_radius, config.max_radius);
|
||||
let segments = 8;
|
||||
let segments = 20;
|
||||
let mut vertices = Vec::with_capacity((segments + 1) * 2);
|
||||
|
||||
for i in 0..=segments {
|
||||
@@ -357,6 +361,34 @@ pub fn smooth_pressure(samples: &[StylusSample]) -> Vec<StylusSample> {
|
||||
smoothed
|
||||
}
|
||||
|
||||
/// Glättet die Positionen mit dem Fenster [0.25, 0.5, 0.25] gegen Digitizer-Rauschen.
|
||||
/// Symmetrisch (ohne Verzögerung), daher bleiben auch kleine Schleifen rund. Anfang, Ende
|
||||
/// und Punkte mit scharfem Knick (über [`CORNER_ANGLE_THRESHOLD_DEG`]) bleiben unverändert,
|
||||
/// damit Ecken spitz bleiben.
|
||||
pub fn smooth_positions(samples: &[StylusSample]) -> Vec<StylusSample> {
|
||||
if samples.len() < 3 {
|
||||
return samples.to_vec();
|
||||
}
|
||||
let cos_limit = CORNER_ANGLE_THRESHOLD_DEG.to_radians().cos();
|
||||
let mut smoothed = samples.to_vec();
|
||||
for i in 1..samples.len() - 1 {
|
||||
let (a, b, c) = (
|
||||
samples[i - 1].position,
|
||||
samples[i].position,
|
||||
samples[i + 1].position,
|
||||
);
|
||||
let (d_in, d_out) = (b - a, c - b);
|
||||
if d_in.length_squared() > 1e-8 && d_out.length_squared() > 1e-8 {
|
||||
let cos = d_in.normalize().dot(d_out.normalize());
|
||||
if cos < cos_limit {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
smoothed[i].position = 0.25 * a + 0.5 * b + 0.25 * c;
|
||||
}
|
||||
smoothed
|
||||
}
|
||||
|
||||
/// Rekursives RDP-Markieren zur Ausdünnung kollinearer Zwischenpunkte.
|
||||
fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, keep: &mut [bool]) {
|
||||
if end <= start + 1 {
|
||||
@@ -402,7 +434,8 @@ pub fn rdp_filter_samples(samples: &[StylusSample], epsilon: f32) -> Vec<StylusS
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`.
|
||||
/// Berechnet die Richtungsänderung zwischen benachbarten Segmenten und findet Ecken mit Winkel > `threshold_deg`
|
||||
/// (nur zwischen Segmenten von mindestens [`CORNER_MIN_SEGMENT_UNITS`] Länge).
|
||||
pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize> {
|
||||
if samples.len() < 3 {
|
||||
return Vec::new();
|
||||
@@ -415,7 +448,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
||||
let l_in = v_in.length();
|
||||
let l_out = v_out.length();
|
||||
|
||||
if l_in > 1e-4 && l_out > 1e-4 {
|
||||
if l_in >= CORNER_MIN_SEGMENT_UNITS && l_out >= CORNER_MIN_SEGMENT_UNITS {
|
||||
let d_in = v_in / l_in;
|
||||
let d_out = v_out / l_out;
|
||||
let dot = d_in.dot(d_out).clamp(-1.0, 1.0);
|
||||
@@ -566,7 +599,8 @@ pub fn generate_finalized_triangle_strip(
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
const CAP_STEPS: usize = 4;
|
||||
// Runde Kappen fein genug unterteilt, dass sie auch stark vergrößert rund wirken
|
||||
const CAP_STEPS: usize = 10;
|
||||
let mut vertices = Vec::new();
|
||||
|
||||
// 3. Runde Start-Kappe vor dem ersten Run
|
||||
@@ -626,7 +660,7 @@ pub fn generate_finalized_triangle_strip(
|
||||
|
||||
/// Finalisiert einen Strich nach dem Zwei-Stufen-Modell:
|
||||
/// 1. Ecken-Erkennung (Corner Preservation bei Richtungsänderung > 65°)
|
||||
/// 2. Druckglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
/// 2. Druck- und Positionsglättung über gewichtetes 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
||||
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
||||
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
||||
@@ -649,8 +683,8 @@ pub fn finalize_stroke_with_config(
|
||||
return Stroke::new(stroke_id, Vec::new(), Vec::new(), color);
|
||||
}
|
||||
|
||||
// 1. Druckglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
let smoothed = smooth_pressure(raw_samples);
|
||||
// 1. Druck- und Positionsglättung über 3-Punkt-Fenster [0.25, 0.5, 0.25]
|
||||
let smoothed = smooth_positions(&smooth_pressure(raw_samples));
|
||||
|
||||
// 2. RDP-Dezimierung (ca. 0.075 mm = 0.75 Einheiten)
|
||||
let decimated = rdp_filter_samples(&smoothed, RDP_TOLERANCE_UNITS);
|
||||
@@ -866,6 +900,31 @@ mod tests {
|
||||
assert!((corner_pt.position.y - 0.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_loops_stay_round() {
|
||||
// Kleines „o“ (Radius 0,8 mm) in 0,1 s mit 200 Hz, mit Vorschub wie im Wort
|
||||
let (r, period, hz) = (8.0_f32, 0.1_f64, 200.0);
|
||||
let center = |t: f64| glam::Vec2::new(500.0 + t as f32 * 40.0, 500.0);
|
||||
let raw: Vec<StylusSample> = (0..=((period * hz) as usize))
|
||||
.map(|i| {
|
||||
let t = i as f64 / hz;
|
||||
let a = std::f32::consts::TAU * (t / period) as f32;
|
||||
StylusSample {
|
||||
position: center(t) + glam::Vec2::new(r * a.sin(), -r * a.cos()),
|
||||
pressure: 0.6,
|
||||
timestamp: t,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let stroke =
|
||||
finalize_stroke_with_config(1, &raw, [0.0; 4], &SplineConfig::from_width_units(5.0));
|
||||
for p in &stroke.points {
|
||||
let d = (p.position - center(p.timestamp)).length();
|
||||
assert!((d - r).abs() < 0.6, "Schleife verformt: Abstand {d}");
|
||||
}
|
||||
assert!(detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pressure_smoothing() {
|
||||
let samples = vec![
|
||||
|
||||
+14
-2
@@ -2338,12 +2338,24 @@ mod tests {
|
||||
let (mut loaded, _) = load_notebook(&dir).unwrap();
|
||||
assert!(!loaded.pages[1].is_loaded);
|
||||
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_err());
|
||||
assert!(crate::export::generate_pdf_document(
|
||||
&loaded,
|
||||
Default::default(),
|
||||
None,
|
||||
Default::default()
|
||||
)
|
||||
.is_err());
|
||||
|
||||
load_all_pages(&dir, &mut loaded).unwrap();
|
||||
assert_eq!(loaded.pages[1].strokes.len(), 1);
|
||||
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
|
||||
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_ok());
|
||||
assert!(crate::export::generate_pdf_document(
|
||||
&loaded,
|
||||
Default::default(),
|
||||
None,
|
||||
Default::default()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+4
-4
@@ -348,7 +348,7 @@ const MAX_TEXTURE_SIDE: f32 = 4096.0;
|
||||
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
||||
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
|
||||
/// handhabbare Texturgröße). `color` ist sRGB wie die Tinte, ebenso die Pixel.
|
||||
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||
let layout = text.layout();
|
||||
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||
@@ -383,7 +383,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
|
||||
}
|
||||
}
|
||||
|
||||
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
|
||||
let rgb = [0, 1, 2].map(|i| (color[i].clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let alpha = color[3].clamp(0.0, 1.0);
|
||||
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||
for c in coverage {
|
||||
@@ -476,8 +476,8 @@ mod tests {
|
||||
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||
assert!(opaque > 100, "{opaque}");
|
||||
// Farbe ist sRGB-kodiert
|
||||
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
||||
// Die Farbe ist schon sRGB und wird unverändert übernommen
|
||||
assert!(image.pixels.chunks(4).all(|p| p[..3] == [217, 51, 51]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+18
-19
@@ -3,20 +3,18 @@
|
||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
||||
|
||||
use crate::document::{PageViewport, UNITS_PER_MM};
|
||||
use crate::document::PageViewport;
|
||||
use glam::{Mat4, Vec2, Vec4};
|
||||
|
||||
/// Minimaler und maximaler Zoomfaktor.
|
||||
pub const MIN_ZOOM: f32 = 0.25;
|
||||
pub const MAX_ZOOM: f32 = 10.0;
|
||||
|
||||
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
||||
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
||||
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
||||
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
||||
|
||||
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
|
||||
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
||||
/// Durchgehende Ansicht: Die Seiten hängen lückenlos untereinander (die Grenze markiert eine
|
||||
/// Linie der Oberfläche) und sind waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
||||
/// aktuellen Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageStack {
|
||||
@@ -33,11 +31,11 @@ impl PageStack {
|
||||
let mut origins = vec![Vec2::ZERO; sizes.len()];
|
||||
let x = |i: usize| (width - sizes[i].x) / 2.0;
|
||||
for i in current + 1..sizes.len() {
|
||||
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
|
||||
let y = origins[i - 1].y + sizes[i - 1].y;
|
||||
origins[i] = Vec2::new(x(i), y);
|
||||
}
|
||||
for i in (0..current).rev() {
|
||||
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
|
||||
let y = origins[i + 1].y - sizes[i].y;
|
||||
origins[i] = Vec2::new(x(i), y);
|
||||
}
|
||||
Self {
|
||||
@@ -55,7 +53,7 @@ impl PageStack {
|
||||
self.sizes.is_empty()
|
||||
}
|
||||
|
||||
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
|
||||
/// Seite, auf deren Höhe `y` liegt (an der Grenze die untere); `None` außerhalb.
|
||||
pub fn page_at(&self, y: f32) -> Option<usize> {
|
||||
// Seiten liegen nach Index sortiert untereinander
|
||||
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
|
||||
@@ -312,7 +310,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const A4: Vec2 = crate::document::A4_SIZE;
|
||||
const PITCH: f32 = A4.y + PAGE_GAP;
|
||||
const PITCH: f32 = A4.y;
|
||||
|
||||
fn a4_stack(count: usize, current: usize) -> PageStack {
|
||||
PageStack::new(vec![A4; count], current)
|
||||
@@ -322,10 +320,11 @@ mod tests {
|
||||
fn test_continuous_layout() {
|
||||
let stack = a4_stack(5, 1);
|
||||
assert_eq!(stack.page_at(100.0), Some(1));
|
||||
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
|
||||
// Lückenlos: direkt unter dem Seitenende beginnt die nächste Seite
|
||||
assert_eq!(stack.page_at(A4.y - 1.0), Some(1));
|
||||
assert_eq!(stack.page_at(A4.y + 1.0), Some(2));
|
||||
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
|
||||
assert_eq!(stack.page_at(-5.0), None);
|
||||
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
|
||||
assert_eq!(stack.page_at(-5.0), Some(0));
|
||||
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
|
||||
|
||||
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
|
||||
@@ -339,7 +338,7 @@ mod tests {
|
||||
.is_empty());
|
||||
// Hinter der letzten Seite: keine weiteren
|
||||
assert_eq!(
|
||||
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
|
||||
a4_stack(5, 3).visible_neighbors(-PITCH + 1.0, PITCH * 4.0),
|
||||
vec![2, 4]
|
||||
);
|
||||
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
||||
@@ -352,13 +351,13 @@ mod tests {
|
||||
fn test_stack_of_different_page_sizes() {
|
||||
let slide = Vec2::new(2970.0, 1671.0);
|
||||
let stack = PageStack::new(vec![slide, A4, slide], 1);
|
||||
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
|
||||
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
||||
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
|
||||
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
|
||||
// Folien waagerecht zur A4-Seite zentriert, direkt darüber und darunter
|
||||
assert_eq!(stack.origins[0], Vec2::new(-435.0, -slide.y));
|
||||
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y));
|
||||
assert_eq!(stack.page_at(A4.y + 10.0), Some(2));
|
||||
let (min, max) = stack.bounds();
|
||||
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
||||
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
|
||||
assert_eq!(min, Vec2::new(-435.0, -slide.y));
|
||||
assert_eq!(max, Vec2::new(2535.0, A4.y + slide.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user