Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901c91d10c | ||
|
|
6caf3f7476 | ||
|
|
9fde59798f | ||
|
|
438106cafd | ||
|
|
392c22bc4a | ||
|
|
6002eee9e8 | ||
|
|
2eab394257 | ||
|
|
6a51ca3aa4 | ||
|
|
6f2b414bb8 | ||
|
|
9dec688b84 | ||
|
|
7e05b33ee3 | ||
|
|
587632b173 | ||
|
|
701fa03f33 | ||
|
|
91a24b9f82 | ||
|
|
64e13e1155 | ||
|
|
8a0477740e | ||
|
|
66679e51ae | ||
|
|
9a39276da4 | ||
|
|
b5c1da07fa | ||
|
|
c52e958c7f | ||
|
|
3e2b627d6a | ||
|
|
616fc57666 | ||
|
|
d150fd1883 | ||
|
|
f9ede537ed | ||
|
|
9571e2121c | ||
|
|
4a7627e461 | ||
|
|
cb11b1f828 | ||
|
|
89005f718e | ||
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d | ||
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 | ||
|
|
5cd45831e2 | ||
|
|
71f6a70829 | ||
|
|
b751b71223 | ||
|
|
afb057692d | ||
|
|
eeae143dc6 | ||
|
|
992c3daad7 | ||
|
|
9684238a53 | ||
|
|
e8674e730f | ||
|
|
7e99d59042 | ||
|
|
7ed971d2a2 | ||
|
|
5ce2c250bb | ||
|
|
d61e1819ed | ||
|
|
12755d8eea | ||
|
|
510035e807 | ||
|
|
1afe4a0f9d | ||
|
|
04666bf389 | ||
|
|
a82402bfce | ||
|
|
06b3aeb311 |
@@ -44,18 +44,28 @@ jobs:
|
|||||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
toolchain: stable
|
||||||
|
# Die Action installiert mit --profile minimal; ohne diese Komponenten
|
||||||
|
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
|
||||||
|
components: rustfmt, clippy
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
- name: Install rsign2 (Release-Signatur)
|
||||||
|
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
|
||||||
|
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
|
||||||
|
|
||||||
- name: Code Quality & Testing (Quality Gates)
|
- name: Code Quality & Testing (Quality Gates)
|
||||||
run: |
|
run: |
|
||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
|
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh
|
||||||
|
env:
|
||||||
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
|
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
||||||
|
|
||||||
- name: Publish Release to Gitea
|
- name: Publish Release to Gitea
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
Generated
+730
-19
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.1.1"
|
version = "0.11.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
rust-version = "1.75"
|
rust-version = "1.92"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||||
@@ -40,6 +40,17 @@ ureq = { version = "2.10", default-features = false, features = ["tls", "gzip",
|
|||||||
url = "2.5"
|
url = "2.5"
|
||||||
octotablet = "0.1.0"
|
octotablet = "0.1.0"
|
||||||
|
|
||||||
|
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
||||||
|
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
||||||
|
minisign-verify = "0.3.0"
|
||||||
|
fs2 = "0.4"
|
||||||
|
hayro = "0.8"
|
||||||
|
hayro-svg = "0.8"
|
||||||
|
|
||||||
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|
||||||
@@ -51,3 +62,5 @@ strip = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
|
||||||
|
naga = { version = "0.19", features = ["wgsl-in"] }
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
- **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.
|
- **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.
|
- **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)
|
### 🔍 Stufenloser Zoom & Pan (Kamera)
|
||||||
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader.
|
||||||
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via Mausrad, Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`).
|
- **Durchgehendes Scrollen:** Seiten hängen untereinander und werden mit Mausrad, Finger oder Touchpad fließend gescrollt; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite angehängt. Abschaltbar im ☰-Menü unter „Ansicht“ (dann Seite für Seite blättern).
|
||||||
|
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 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.
|
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
|
||||||
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
|
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
|
||||||
|
|
||||||
@@ -52,8 +54,9 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
|
|
||||||
### 📄 Seiten & Hintergründe
|
### 📄 Seiten & Hintergründe
|
||||||
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
|
- **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).
|
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
|
||||||
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
|
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anhängen oder direkt nach der aktuellen Seite einfügen (☰-Menü oder Seitenauswahl, z. B. zwischen zwei PDF-Folien), Seitensprung-Menü.
|
||||||
|
|
||||||
### 💾 Autosave & Persistenz
|
### 💾 Autosave & Persistenz
|
||||||
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
||||||
@@ -71,7 +74,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
|||||||
|
|
||||||
- **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).
|
- **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.
|
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
|
||||||
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, SHA-256-Prüfung und atomarer In-Place Austausch.
|
- **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.
|
||||||
|
|
||||||
### 1. Release-Pakete bauen
|
### 1. Release-Pakete bauen
|
||||||
|
|
||||||
@@ -84,6 +87,23 @@ Ergebnis in `target/`:
|
|||||||
- `ink-notes_0.1.0_amd64.deb`
|
- `ink-notes_0.1.0_amd64.deb`
|
||||||
- `ink-notes_0.1.0_x86_64.AppImage`
|
- `ink-notes_0.1.0_x86_64.AppImage`
|
||||||
- `SHA256SUMS.txt`
|
- `SHA256SUMS.txt`
|
||||||
|
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
|
||||||
|
|
||||||
|
### 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):
|
||||||
|
|
||||||
|
```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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
|
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
|
||||||
|
|
||||||
@@ -123,15 +143,15 @@ export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
|
|||||||
|
|
||||||
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
||||||
|
|
||||||
1. **SHA-256-Integritätsprüfung:**
|
1. **Minisign-Signatur (Pflicht):**
|
||||||
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen.
|
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
|
||||||
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`).
|
- 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.
|
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
|
||||||
- *Hinweis zur Authentizität:* Eine Prüfsumme vom selben Release-Server garantiert Schutz gegen Übertragungsfehler und Datei-Beschädigungen, jedoch keine kryptografische Nicht-Abstreitbarkeit gegenüber einem kompromittierten Server.
|
|
||||||
2. **Signatur-Ausblick:**
|
|
||||||
- Die Updater-Architektur enthält Schnittstellen für optionale `.sig`/`.minisig`-Signaturen zur zukünftigen End-to-End-Kryptoverifikation über öffentliche Schlüssel.
|
|
||||||
3. **Redirect-Schutz & HTTPS:**
|
3. **Redirect-Schutz & HTTPS:**
|
||||||
- Alle API- und Download-Endpunkte erzwingen standardmäßig 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.
|
- 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:**
|
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`).
|
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
|
||||||
@@ -202,10 +222,12 @@ cargo run --release
|
|||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| **Zeichnen** | Linke Maustaste / Stiftspitze |
|
| **Zeichnen** | Linke Maustaste / Stiftspitze |
|
||||||
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
|
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
|
||||||
| **Zoom zentriert auf Cursor** | Mausrad / Touchpad-Pinch |
|
| **Scrollen (durchgehende Ansicht)** | Mausrad (`Umschalt` = waagerecht) / ein Finger |
|
||||||
|
| **Zoom zentriert auf Cursor** | `Strg + Mausrad` / Touchpad-Pinch (Einzelseiten-Ansicht: Mausrad) |
|
||||||
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
|
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
|
||||||
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
|
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
|
||||||
| **Auswahlwerkzeug (Lasso)** | `V` |
|
| **Auswahlwerkzeug (Lasso)** | `V` |
|
||||||
|
| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
|
||||||
| **Auswahl um 90° drehen** | `R` |
|
| **Auswahl um 90° drehen** | `R` |
|
||||||
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
|
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
|
||||||
| **Auswahl löschen** | `Entf` oder `Backspace` |
|
| **Auswahl löschen** | `Entf` oder `Backspace` |
|
||||||
|
|||||||
+54
-51
@@ -1,6 +1,6 @@
|
|||||||
# Dateiformat & Speicherstruktur von ink-notes
|
# Dateiformat & Speicherstruktur von ink-notes
|
||||||
|
|
||||||
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
|
|||||||
└── notebooks/
|
└── notebooks/
|
||||||
├── <notebook_id_1>/
|
├── <notebook_id_1>/
|
||||||
│ ├── manifest.json
|
│ ├── manifest.json
|
||||||
|
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
|
||||||
│ ├── page_1.json
|
│ ├── page_1.json
|
||||||
|
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
|
||||||
│ ├── page_2.json
|
│ ├── page_2.json
|
||||||
│ └── ...
|
│ └── ...
|
||||||
└── <notebook_id_2>/
|
└── <notebook_id_2>/
|
||||||
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"format_version": 2,
|
||||||
"id": "notebook-a1b2c3d4",
|
"id": "notebook-a1b2c3d4",
|
||||||
"title": "Mathematik Vorlesung",
|
"title": "Mathematik Vorlesung",
|
||||||
"version": 1,
|
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"file_name": "page_1.json"
|
"background": "Grid5mm"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"file_name": "page_2.json"
|
"background": "DotGrid"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"page_ids": [1, 2],
|
||||||
"current_page_index": 0,
|
"current_page_index": 0,
|
||||||
"next_page_id": 3,
|
"next_page_id": 3,
|
||||||
"next_stroke_id": 42,
|
"next_stroke_id": 42,
|
||||||
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Felder:
|
### Felder:
|
||||||
|
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
|
||||||
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
||||||
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
||||||
- `version`: Format-Versionsnummer (aktuell `1`).
|
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
|
||||||
- `pages`: Geordnete Liste von Seitenreferenzen.
|
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
|
||||||
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
||||||
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
||||||
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
|
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
|
||||||
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
|||||||
## 3. Seitendatei (`page_<id>.json`)
|
## 3. Seitendatei (`page_<id>.json`)
|
||||||
|
|
||||||
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
|
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.
|
||||||
|
|
||||||
```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}]}
|
||||||
"id": 1,
|
|
||||||
"background": "Grid5mm",
|
|
||||||
"strokes": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"color": [0.12, 0.12, 0.14, 1.0],
|
|
||||||
"width_mm": 0.5,
|
|
||||||
"samples": [
|
|
||||||
{
|
|
||||||
"x": 350.2,
|
|
||||||
"y": 420.5,
|
|
||||||
"pressure": 0.65,
|
|
||||||
"timestamp": 12.345
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 352.1,
|
|
||||||
"y": 425.8,
|
|
||||||
"pressure": 0.70,
|
|
||||||
"timestamp": 12.355
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"recognized_shape": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"color": [0.15, 0.35, 0.85, 1.0],
|
|
||||||
"width_mm": 0.8,
|
|
||||||
"samples": [],
|
|
||||||
"recognized_shape": {
|
|
||||||
"Rectangle": {
|
|
||||||
"top_left": { "x": 500.0, "y": 600.0 },
|
|
||||||
"width": 300.0,
|
|
||||||
"height": 200.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Felder:
|
### Felder:
|
||||||
|
- `format_version`: Format-Versionsnummer (`2`).
|
||||||
- `id`: Eindeutige ID der Seite.
|
- `id`: Eindeutige ID der Seite.
|
||||||
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
||||||
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
||||||
- `id`: Eindeutige ID des Strichs.
|
- `id`: Eindeutige ID des Strichs.
|
||||||
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
|
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
|
||||||
- `width_mm`: Basis-Strichbreite in Millimetern.
|
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
|
||||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
|
- `shape`: Optionales Enum für geometrische Vektorformen:
|
||||||
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
|
|
||||||
- `Line { start, end }`
|
- `Line { start, end }`
|
||||||
- `Rectangle { top_left, width, height }`
|
- `Rectangle { top_left, width, height }`
|
||||||
- `Circle { center, radius }`
|
- `Circle { center, radius }`
|
||||||
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
|
|||||||
- `Triangle { p1, p2, p3 }`
|
- `Triangle { p1, p2, p3 }`
|
||||||
- `Rhombus { center, width, height }`
|
- `Rhombus { center, width, height }`
|
||||||
- `Octahedron { center, radius }`
|
- `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Atomares Schreibverfahren
|
## 4. Datensicherheit & Migration
|
||||||
|
|
||||||
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
|
### Atomares Schreiben & Verzeichnis-Sync
|
||||||
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
|
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.
|
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`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Versionshistorie
|
||||||
|
|
||||||
|
| 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). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -3,12 +3,32 @@
|
|||||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **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
|
||||||
|
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
|
||||||
|
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
|
||||||
|
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
|
||||||
|
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
|
||||||
|
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
|
||||||
|
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
|
||||||
|
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
|
||||||
|
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
|
||||||
|
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
|
||||||
|
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
|
||||||
|
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
|
||||||
|
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
|
||||||
|
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||||
|
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl 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
|
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
|
||||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
|
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases
|
- **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
|
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
|
||||||
|
|
||||||
### Installation:
|
### Installation:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
||||||
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/
|
# Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
|||||||
cd "${ROOT_DIR}/target"
|
cd "${ROOT_DIR}/target"
|
||||||
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
||||||
|
|
||||||
|
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
|
||||||
|
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
|
||||||
|
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
|
||||||
|
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
|
||||||
|
rm -f "${SIGNATURE_FILE}"
|
||||||
|
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
|
||||||
|
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
|
||||||
|
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
|
||||||
|
if [ -z "${EMBEDDED_PUBKEY}" ]; then
|
||||||
|
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v rsign >/dev/null 2>&1; then
|
||||||
|
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
|
||||||
|
KEY_FILE="$(mktemp)"
|
||||||
|
trap 'rm -f "${KEY_FILE}"' EXIT
|
||||||
|
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
|
||||||
|
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
|
||||||
|
rm -f "${KEY_FILE}"
|
||||||
|
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
|
||||||
|
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
|
||||||
|
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|
||||||
|
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
|
||||||
|
echo "${VERIFY_OUT}"
|
||||||
|
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
|
||||||
|
rm -f "${SIGNATURE_FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "${VERIFY_OUT}"
|
||||||
|
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
|
||||||
|
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
|
||||||
|
echo " Installierte Apps lehnen ein solches Release als Update ab."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
||||||
|
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
||||||
cat "${CHECKSUM_FILE}"
|
cat "${CHECKSUM_FILE}"
|
||||||
|
|||||||
Executable
+82
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# scripts/crash-test.sh
|
||||||
|
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
|
||||||
|
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
|
||||||
|
# und verifiziert die Integrität der Notizbücher und Seiten.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
ITERATIONS="${1:-200}"
|
||||||
|
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
|
||||||
|
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
|
||||||
|
|
||||||
|
echo "=================================================================="
|
||||||
|
echo " ink-notes Crash- & Datenverlust-Stresstest"
|
||||||
|
echo " Iterationen: ${ITERATIONS}"
|
||||||
|
echo " Testverzeichnis: ${TEST_DIR}"
|
||||||
|
echo "=================================================================="
|
||||||
|
|
||||||
|
# 1. Sicherstellen, dass das Test-Binary gebaut ist
|
||||||
|
if [ ! -f "${CRASH_SIM_BIN}" ]; then
|
||||||
|
echo "Baue crash_sim Release-Binary..."
|
||||||
|
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
|
||||||
|
rm -rf "${TEST_DIR}"
|
||||||
|
mkdir -p "${TEST_DIR}"
|
||||||
|
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
|
||||||
|
|
||||||
|
SUCCESS_COUNT=0
|
||||||
|
INTERRUPTED_WRITES=0
|
||||||
|
|
||||||
|
trap 'rm -rf "${TEST_DIR}"' EXIT
|
||||||
|
|
||||||
|
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
|
||||||
|
|
||||||
|
for i in $(seq 1 "${ITERATIONS}"); do
|
||||||
|
# Worker im Hintergrund starten
|
||||||
|
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
|
||||||
|
WORKER_PID=$!
|
||||||
|
|
||||||
|
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
|
||||||
|
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
|
||||||
|
MS_DELAY=$(( (RANDOM % 33) + 2 ))
|
||||||
|
DELAY="0.$(printf "%03d" "$MS_DELAY")"
|
||||||
|
sleep "${DELAY}"
|
||||||
|
|
||||||
|
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
|
||||||
|
if kill -9 "${WORKER_PID}" 2>/dev/null; then
|
||||||
|
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
|
||||||
|
fi
|
||||||
|
wait "${WORKER_PID}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Integritäts- und Konsistenzprüfung
|
||||||
|
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ FEHLER in Iteration ${i}!"
|
||||||
|
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
|
||||||
|
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
|
||||||
|
trap - EXIT
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||||
|
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
|
||||||
|
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=================================================================="
|
||||||
|
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
|
||||||
|
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
|
||||||
|
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
|
||||||
|
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
|
||||||
|
echo " - Keine unvollständigen Atom-Schreibungen"
|
||||||
|
echo "=================================================================="
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
||||||
# Lädt .deb, .AppImage und SHA256SUMS.txt idempotent als Release-Assets hoch.
|
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
|
|||||||
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
||||||
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
||||||
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
||||||
|
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
|
||||||
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
||||||
|
|
||||||
if [ ! -f "${TEMPLATE_FILE}" ]; then
|
if [ ! -f "${TEMPLATE_FILE}" ]; then
|
||||||
@@ -46,6 +47,12 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
|
|||||||
"${SCRIPT_DIR}/build-release.sh"
|
"${SCRIPT_DIR}/build-release.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ohne Signatur lehnen installierte Apps das Release als Update ab
|
||||||
|
if [ ! -f "${SIGNATURE_FILE}" ]; then
|
||||||
|
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
@@ -230,6 +237,7 @@ except Exception:
|
|||||||
upload_asset "${DEB_FILE}"
|
upload_asset "${DEB_FILE}"
|
||||||
upload_asset "${APPIMAGE_FILE}"
|
upload_asset "${APPIMAGE_FILE}"
|
||||||
upload_asset "${CHECKSUMS_FILE}"
|
upload_asset "${CHECKSUMS_FILE}"
|
||||||
|
upload_asset "${SIGNATURE_FILE}"
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
||||||
|
|||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
|
||||||
|
//!
|
||||||
|
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
|
||||||
|
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
|
||||||
|
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
|
||||||
|
//! Kopien auf anderen Seiten immer funktionieren.
|
||||||
|
//!
|
||||||
|
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
|
||||||
|
//! werden als PNG gespeichert.
|
||||||
|
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::storage;
|
||||||
|
|
||||||
|
/// Name des Unterordners mit den Bilddateien.
|
||||||
|
pub const ASSETS_DIR: &str = "assets";
|
||||||
|
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
|
||||||
|
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
|
||||||
|
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
|
||||||
|
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
|
||||||
|
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
|
||||||
|
/// Größte Dateigröße beim Einfügen.
|
||||||
|
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Fehler beim Einfügen oder Laden eines Bildes.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AssetError {
|
||||||
|
/// Format nicht unterstützt oder Datei beschädigt
|
||||||
|
Decode(String),
|
||||||
|
/// Datei zu groß
|
||||||
|
TooLarge(usize),
|
||||||
|
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
|
||||||
|
InvalidName(String),
|
||||||
|
Io(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AssetError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
|
||||||
|
AssetError::TooLarge(bytes) => write!(
|
||||||
|
f,
|
||||||
|
"Bild ist zu groß ({} MB, höchstens {} MB)",
|
||||||
|
bytes / (1024 * 1024),
|
||||||
|
MAX_IMPORT_BYTES / (1024 * 1024)
|
||||||
|
),
|
||||||
|
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
|
||||||
|
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AssetError {}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for AssetError {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
AssetError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis eines erfolgreichen Imports.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ImportedImage {
|
||||||
|
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
|
||||||
|
pub asset: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RgbaImage {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub pixels: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png`, `.jpg` oder `.pdf`. Verhindert, dass
|
||||||
|
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
|
||||||
|
pub fn is_valid_asset_name(name: &str) -> bool {
|
||||||
|
let Some((hash, ext)) = name.split_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
hash.len() == 64
|
||||||
|
&& hash
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||||
|
&& matches!(ext, "png" | "jpg" | "pdf")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bilddateien eines Notizbuchs.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AssetStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AssetStore {
|
||||||
|
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
|
||||||
|
pub fn new(notebook_dir: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
dir: notebook_dir.join(ASSETS_DIR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pfad einer Bilddatei (nur für gültige Namen).
|
||||||
|
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
|
||||||
|
if is_valid_asset_name(asset) {
|
||||||
|
Ok(self.dir.join(asset))
|
||||||
|
} else {
|
||||||
|
Err(AssetError::InvalidName(asset.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
|
||||||
|
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
|
||||||
|
if bytes.len() > MAX_IMPORT_BYTES {
|
||||||
|
return Err(AssetError::TooLarge(bytes.len()));
|
||||||
|
}
|
||||||
|
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||||
|
let decoded = image::load_from_memory_with_format(bytes, format)
|
||||||
|
.map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||||
|
|
||||||
|
let fits =
|
||||||
|
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
|
||||||
|
if format == image::ImageFormat::Jpeg && fits {
|
||||||
|
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
|
||||||
|
return self.store(bytes, "jpg", decoded.width(), decoded.height());
|
||||||
|
}
|
||||||
|
let decoded = if fits {
|
||||||
|
decoded
|
||||||
|
} else {
|
||||||
|
decoded.resize(
|
||||||
|
MAX_IMPORT_DIMENSION,
|
||||||
|
MAX_IMPORT_DIMENSION,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let png = encode_png(&decoded)?;
|
||||||
|
self.store(&png, "png", decoded.width(), decoded.height())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übernimmt eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein).
|
||||||
|
pub fn import_pdf(&self, bytes: &[u8]) -> Result<String, AssetError> {
|
||||||
|
let hash = Sha256::digest(bytes);
|
||||||
|
let asset = format!("{}.pdf", hex(&hash));
|
||||||
|
let path = self.path_of(&asset)?;
|
||||||
|
if !path.exists() {
|
||||||
|
storage::atomic_write_file(&path, bytes)?;
|
||||||
|
}
|
||||||
|
Ok(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
|
||||||
|
pub fn import_rgba(
|
||||||
|
&self,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pixels: Vec<u8>,
|
||||||
|
) -> Result<ImportedImage, AssetError> {
|
||||||
|
let buffer = image::RgbaImage::from_raw(width, height, pixels)
|
||||||
|
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
|
||||||
|
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
|
||||||
|
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
|
||||||
|
decoded = decoded.resize(
|
||||||
|
MAX_IMPORT_DIMENSION,
|
||||||
|
MAX_IMPORT_DIMENSION,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let png = encode_png(&decoded)?;
|
||||||
|
self.store(&png, "png", decoded.width(), decoded.height())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
|
||||||
|
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
|
||||||
|
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
|
||||||
|
let target = self.path_of(asset)?;
|
||||||
|
if target.exists() || other.dir == self.dir {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(other.path_of(asset)?)?;
|
||||||
|
storage::atomic_write_file(&target, &bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest die gespeicherte Bilddatei unverändert.
|
||||||
|
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
|
||||||
|
Ok(std::fs::read(self.path_of(asset)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
|
||||||
|
/// `max_dimension` Pixel Kantenlänge.
|
||||||
|
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
|
||||||
|
let bytes = self.read_bytes(asset)?;
|
||||||
|
let mut decoded =
|
||||||
|
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||||
|
if decoded.width() > max_dimension || decoded.height() > max_dimension {
|
||||||
|
decoded = decoded.resize(
|
||||||
|
max_dimension,
|
||||||
|
max_dimension,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let rgba = decoded.into_rgba8();
|
||||||
|
Ok(RgbaImage {
|
||||||
|
width: rgba.width(),
|
||||||
|
height: rgba.height(),
|
||||||
|
pixels: rgba.into_raw(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(
|
||||||
|
&self,
|
||||||
|
bytes: &[u8],
|
||||||
|
ext: &str,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<ImportedImage, AssetError> {
|
||||||
|
let hash = Sha256::digest(bytes);
|
||||||
|
let asset = format!("{}.{ext}", hex(&hash));
|
||||||
|
let path = self.path_of(&asset)?;
|
||||||
|
if !path.exists() {
|
||||||
|
storage::atomic_write_file(&path, bytes)?;
|
||||||
|
}
|
||||||
|
Ok(ImportedImage {
|
||||||
|
asset,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image
|
||||||
|
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||||
|
Ok(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
|
||||||
|
pub fn is_supported_image_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| {
|
||||||
|
matches!(
|
||||||
|
e.to_ascii_lowercase().as_str(),
|
||||||
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
|
||||||
|
pub(crate) fn sample_image_bytes(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
format: image::ImageFormat,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let img = image::RgbImage::from_fn(width, height, |x, y| {
|
||||||
|
image::Rgb([
|
||||||
|
(x * 255 / width.max(1)) as u8,
|
||||||
|
(y * 255 / height.max(1)) as u8,
|
||||||
|
90,
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgb8(img)
|
||||||
|
.write_to(&mut Cursor::new(&mut bytes), format)
|
||||||
|
.unwrap();
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_asset_names_are_validated() {
|
||||||
|
let hash = "a".repeat(64);
|
||||||
|
assert!(is_valid_asset_name(&format!("{hash}.png")));
|
||||||
|
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
|
||||||
|
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
|
||||||
|
assert!(!is_valid_asset_name("../../etc/passwd"));
|
||||||
|
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
|
||||||
|
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
|
||||||
|
assert!(!is_valid_asset_name(""));
|
||||||
|
|
||||||
|
let store = AssetStore::new(Path::new("/tmp/nb"));
|
||||||
|
assert!(store.path_of("../manifest.json").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_png_jpeg_and_deduplicate() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = AssetStore::new(dir.path());
|
||||||
|
|
||||||
|
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
|
||||||
|
let a = store.import_bytes(&png).unwrap();
|
||||||
|
assert_eq!((a.width, a.height), (40, 20));
|
||||||
|
assert!(a.asset.ends_with(".png"));
|
||||||
|
assert!(store.path_of(&a.asset).unwrap().exists());
|
||||||
|
|
||||||
|
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
|
||||||
|
let b = store.import_bytes(&png).unwrap();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
|
||||||
|
|
||||||
|
// JPEG bleibt byte-identisch
|
||||||
|
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
|
||||||
|
let c = store.import_bytes(&jpg).unwrap();
|
||||||
|
assert!(c.asset.ends_with(".jpg"));
|
||||||
|
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
|
||||||
|
|
||||||
|
// BMP wird als PNG gespeichert
|
||||||
|
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
|
||||||
|
let d = store.import_bytes(&bmp).unwrap();
|
||||||
|
assert!(d.asset.ends_with(".png"));
|
||||||
|
|
||||||
|
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
|
||||||
|
assert_eq!((rgba.width, rgba.height), (40, 20));
|
||||||
|
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
|
||||||
|
// Verkleinerung für die Anzeige behält das Seitenverhältnis
|
||||||
|
let small = store.load_rgba(&a.asset, 10).unwrap();
|
||||||
|
assert_eq!((small.width, small.height), (10, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_rejects_garbage_and_copies_between_notebooks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let store = AssetStore::new(&dir.path().join("a"));
|
||||||
|
assert!(matches!(
|
||||||
|
store.import_bytes(b"kein Bild"),
|
||||||
|
Err(AssetError::Decode(_))
|
||||||
|
));
|
||||||
|
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
|
||||||
|
|
||||||
|
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
|
||||||
|
let other = AssetStore::new(&dir.path().join("b"));
|
||||||
|
assert!(other.read_bytes(&imported.asset).is_err());
|
||||||
|
other.copy_from(&store, &imported.asset).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
other.read_bytes(&imported.asset).unwrap(),
|
||||||
|
store.read_bytes(&imported.asset).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_supported_image_paths() {
|
||||||
|
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
|
||||||
|
assert!(is_supported_image_path(Path::new("a.webp")));
|
||||||
|
assert!(!is_supported_image_path(Path::new("a.pdf")));
|
||||||
|
assert!(!is_supported_image_path(Path::new("png")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+899
-85
File diff suppressed because it is too large
Load Diff
+215
-42
@@ -1,7 +1,9 @@
|
|||||||
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
|
use crate::document::PageBackground;
|
||||||
|
|
||||||
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
|
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm).
|
||||||
pub const LINE_THICKNESS: f32 = 1.5;
|
pub const LINE_THICKNESS: f32 = 1.5;
|
||||||
@@ -13,43 +15,129 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
|
|||||||
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
|
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
|
||||||
pub const LINED_SPACING: f32 = 75.0;
|
pub const LINED_SPACING: f32 = 75.0;
|
||||||
|
|
||||||
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
|
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||||
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
|
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||||
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
|
///
|
||||||
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||||
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
|
#[serde(rename_all = "lowercase")]
|
||||||
r: 0.16,
|
pub enum PaperTheme {
|
||||||
g: 0.16,
|
#[default]
|
||||||
b: 0.18,
|
White,
|
||||||
a: 1.0,
|
/// Cremefarben (augenschonend, wie Recyclingpapier)
|
||||||
};
|
Cream,
|
||||||
|
/// Dunkelgrau (Nachtmodus)
|
||||||
|
DarkGray,
|
||||||
|
/// Schwarz (OLED)
|
||||||
|
Black,
|
||||||
|
}
|
||||||
|
|
||||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
|
impl PaperTheme {
|
||||||
pub fn generate_paper_quad() -> Vec<InkVertex> {
|
pub const ALL: [PaperTheme; 4] = [
|
||||||
|
PaperTheme::White,
|
||||||
|
PaperTheme::Cream,
|
||||||
|
PaperTheme::DarkGray,
|
||||||
|
PaperTheme::Black,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PaperTheme::White => "Weiß",
|
||||||
|
PaperTheme::Cream => "Creme",
|
||||||
|
PaperTheme::DarkGray => "Dunkelgrau",
|
||||||
|
PaperTheme::Black => "Schwarz",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
|
||||||
|
pub fn is_dark(self) -> bool {
|
||||||
|
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Farbe des Blatts.
|
||||||
|
pub fn paper_color(self) -> [f32; 4] {
|
||||||
|
match self {
|
||||||
|
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
|
||||||
|
// sRGB #FBF5E6
|
||||||
|
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
|
||||||
|
// sRGB #2B2B2E
|
||||||
|
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
|
||||||
|
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Farbe von Karos, Linien und Punkten.
|
||||||
|
pub fn pattern_color(self) -> [f32; 4] {
|
||||||
|
match self {
|
||||||
|
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
|
||||||
|
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
|
||||||
|
// sRGB #48484D bzw. #38383C
|
||||||
|
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
|
||||||
|
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
|
||||||
|
/// das Blatt abhebt.
|
||||||
|
pub fn desk_color(self) -> wgpu::Color {
|
||||||
|
let (r, g, b) = match self {
|
||||||
|
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
|
||||||
|
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||||
|
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||||
|
};
|
||||||
|
wgpu::Color { r, g, b, a: 1.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
|
||||||
|
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
|
||||||
|
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
|
||||||
|
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
|
||||||
|
let max = color[0].max(color[1]).max(color[2]);
|
||||||
|
let min = color[0].min(color[1]).min(color[2]);
|
||||||
|
let lift = (1.0 - max - min).max(0.0);
|
||||||
|
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erzeugt die 6 Vertizes (2 Dreiecke) für ein Blatt der Größe `size`.
|
||||||
|
pub fn generate_paper_quad(color: [f32; 4], size: glam::Vec2) -> Vec<InkVertex> {
|
||||||
|
let (page_width, page_height) = (size.x, size.y);
|
||||||
vec![
|
vec![
|
||||||
InkVertex::new(0.0, 0.0, PAPER_COLOR),
|
InkVertex::new(0.0, 0.0, color),
|
||||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
InkVertex::new(page_width, 0.0, color),
|
||||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
InkVertex::new(0.0, page_height, color),
|
||||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
InkVertex::new(0.0, page_height, color),
|
||||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
InkVertex::new(page_width, 0.0, color),
|
||||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
|
InkVertex::new(page_width, page_height, color),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) {
|
fn push_quad(
|
||||||
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
vertices: &mut Vec<InkVertex>,
|
||||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
color: [f32; 4],
|
||||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
x_min: f32,
|
||||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
y_min: f32,
|
||||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
x_max: f32,
|
||||||
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
|
y_max: f32,
|
||||||
|
) {
|
||||||
|
vertices.push(InkVertex::new(x_min, y_min, color));
|
||||||
|
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||||
|
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||||
|
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||||
|
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||||
|
vertices.push(InkVertex::new(x_max, y_max, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
|
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
|
||||||
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
/// einem Blatt der Größe `size`.
|
||||||
|
pub fn generate_background_mesh(
|
||||||
|
background: PageBackground,
|
||||||
|
color: [f32; 4],
|
||||||
|
size: glam::Vec2,
|
||||||
|
) -> Vec<InkVertex> {
|
||||||
|
let (page_width, page_height) = (size.x, size.y);
|
||||||
match background {
|
match background {
|
||||||
PageBackground::Blank => Vec::new(),
|
PageBackground::Blank => Vec::new(),
|
||||||
PageBackground::Grid5mm => {
|
PageBackground::Grid5mm => {
|
||||||
@@ -58,15 +146,29 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
|
|
||||||
// Vertikale Linien alle 5 mm
|
// Vertikale Linien alle 5 mm
|
||||||
let mut x = GRID_5MM_SPACING;
|
let mut x = GRID_5MM_SPACING;
|
||||||
while x < PAGE_WIDTH {
|
while x < page_width {
|
||||||
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
|
push_quad(
|
||||||
|
&mut vertices,
|
||||||
|
color,
|
||||||
|
x - half_t,
|
||||||
|
0.0,
|
||||||
|
x + half_t,
|
||||||
|
page_height,
|
||||||
|
);
|
||||||
x += GRID_5MM_SPACING;
|
x += GRID_5MM_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizontale Linien alle 5 mm
|
// Horizontale Linien alle 5 mm
|
||||||
let mut y = GRID_5MM_SPACING;
|
let mut y = GRID_5MM_SPACING;
|
||||||
while y < PAGE_HEIGHT {
|
while y < page_height {
|
||||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
push_quad(
|
||||||
|
&mut vertices,
|
||||||
|
color,
|
||||||
|
0.0,
|
||||||
|
y - half_t,
|
||||||
|
page_width,
|
||||||
|
y + half_t,
|
||||||
|
);
|
||||||
y += GRID_5MM_SPACING;
|
y += GRID_5MM_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +183,15 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
let margin_bottom = 150.0;
|
let margin_bottom = 150.0;
|
||||||
|
|
||||||
let mut y = margin_top;
|
let mut y = margin_top;
|
||||||
while y <= PAGE_HEIGHT - margin_bottom {
|
while y <= page_height - margin_bottom {
|
||||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
push_quad(
|
||||||
|
&mut vertices,
|
||||||
|
color,
|
||||||
|
0.0,
|
||||||
|
y - half_t,
|
||||||
|
page_width,
|
||||||
|
y + half_t,
|
||||||
|
);
|
||||||
y += LINED_SPACING;
|
y += LINED_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +202,12 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
let half_d = DOT_SIZE / 2.0;
|
let half_d = DOT_SIZE / 2.0;
|
||||||
|
|
||||||
let mut y = GRID_5MM_SPACING;
|
let mut y = GRID_5MM_SPACING;
|
||||||
while y < PAGE_HEIGHT {
|
while y < page_height {
|
||||||
let mut x = GRID_5MM_SPACING;
|
let mut x = GRID_5MM_SPACING;
|
||||||
while x < PAGE_WIDTH {
|
while x < page_width {
|
||||||
push_quad(
|
push_quad(
|
||||||
&mut vertices,
|
&mut vertices,
|
||||||
|
color,
|
||||||
x - half_d,
|
x - half_d,
|
||||||
y - half_d,
|
y - half_d,
|
||||||
x + half_d,
|
x + half_d,
|
||||||
@@ -117,20 +227,29 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_paper_quad_generation() {
|
fn test_paper_quad_generation() {
|
||||||
let quad = generate_paper_quad();
|
let quad = generate_paper_quad(PaperTheme::White.paper_color(), A4_SIZE);
|
||||||
assert_eq!(quad.len(), 6);
|
assert_eq!(quad.len(), 6);
|
||||||
assert_eq!(quad[0].position, [0.0, 0.0]);
|
assert_eq!(quad[0].position, [0.0, 0.0]);
|
||||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||||
|
let slide = generate_paper_quad(
|
||||||
|
PaperTheme::White.paper_color(),
|
||||||
|
glam::Vec2::new(2970.0, 1671.0),
|
||||||
|
);
|
||||||
|
assert_eq!(slide[5].position, [2970.0, 1671.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_background_meshes() {
|
fn test_background_meshes() {
|
||||||
let blank = generate_background_mesh(PageBackground::Blank);
|
let blank = generate_background_mesh(PageBackground::Blank, PATTERN, A4_SIZE);
|
||||||
assert!(blank.is_empty());
|
assert!(blank.is_empty());
|
||||||
|
|
||||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN, A4_SIZE);
|
||||||
assert!(!grid.is_empty());
|
assert!(!grid.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
grid.len() % 6,
|
grid.len() % 6,
|
||||||
@@ -138,12 +257,66 @@ mod tests {
|
|||||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||||
);
|
);
|
||||||
|
|
||||||
let lined = generate_background_mesh(PageBackground::Lined);
|
let lined = generate_background_mesh(PageBackground::Lined, PATTERN, A4_SIZE);
|
||||||
assert!(!lined.is_empty());
|
assert!(!lined.is_empty());
|
||||||
assert_eq!(lined.len() % 6, 0);
|
assert_eq!(lined.len() % 6, 0);
|
||||||
|
|
||||||
let dots = generate_background_mesh(PageBackground::DotGrid);
|
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN, A4_SIZE);
|
||||||
assert!(!dots.is_empty());
|
assert!(!dots.is_empty());
|
||||||
assert_eq!(dots.len() % 6, 0);
|
assert_eq!(dots.len() % 6, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_background_mesh_uses_theme_color() {
|
||||||
|
let dark = PaperTheme::Black.pattern_color();
|
||||||
|
let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
|
||||||
|
assert!(mesh.iter().all(|v| v.color == dark));
|
||||||
|
let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
|
||||||
|
assert!(quad
|
||||||
|
.iter()
|
||||||
|
.all(|v| v.color == PaperTheme::Cream.paper_color()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_adapt_ink_color() {
|
||||||
|
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||||
|
// Schwarz wird Weiß, Deckkraft bleibt
|
||||||
|
assert!(close(
|
||||||
|
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
|
||||||
|
[1.0, 1.0, 1.0, 0.7]
|
||||||
|
));
|
||||||
|
// Notiz-Schwarz wird hell
|
||||||
|
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
|
||||||
|
assert!(ink[0] > 0.85 && ink[2] > 0.85);
|
||||||
|
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
|
||||||
|
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
|
||||||
|
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
|
||||||
|
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
|
||||||
|
for c in [
|
||||||
|
[0.85, 0.20, 0.20, 1.0],
|
||||||
|
[0.15, 0.35, 0.85, 1.0],
|
||||||
|
[1.0, 0.95, 0.1, 0.35],
|
||||||
|
[1.0, 1.0, 1.0, 1.0],
|
||||||
|
] {
|
||||||
|
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
|
||||||
|
}
|
||||||
|
// Ergebnis ist nie heller als Weiß
|
||||||
|
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
|
||||||
|
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_paper_themes_contrast() {
|
||||||
|
for theme in PaperTheme::ALL {
|
||||||
|
let paper = theme.paper_color()[0];
|
||||||
|
let pattern = theme.pattern_color()[0];
|
||||||
|
assert!(
|
||||||
|
(paper - pattern).abs() > 0.03,
|
||||||
|
"{theme:?}: Muster hebt sich nicht vom Papier ab"
|
||||||
|
);
|
||||||
|
// Helles Papier ↔ dunkles Papier
|
||||||
|
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Crash-Simulationstesthelfer für ink-notes.
|
||||||
|
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
|
||||||
|
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use glam::Vec2;
|
||||||
|
use ink_notes::document::{Notebook, PageBackground, StylusSample};
|
||||||
|
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
|
||||||
|
use ink_notes::storage::{
|
||||||
|
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
|
||||||
|
NotebookManifest, PageData,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let nb = Notebook::new();
|
||||||
|
let id = dir
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("crash_test");
|
||||||
|
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
|
||||||
|
m.sync_from_notebook(&nb);
|
||||||
|
save_notebook(dir, &nb, &mut m)?;
|
||||||
|
println!("[init] Initialized clean notebook in {:?}", dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_worker(dir: &Path) {
|
||||||
|
let (mut notebook, mut manifest) = match load_notebook(dir) {
|
||||||
|
Ok((nb, m)) => (nb, m),
|
||||||
|
Err(_) => {
|
||||||
|
let nb = Notebook::new();
|
||||||
|
let id = dir
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("crash_test");
|
||||||
|
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
|
||||||
|
m.sync_from_notebook(&nb);
|
||||||
|
let _ = save_notebook(dir, &nb, &mut m);
|
||||||
|
(nb, m)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let spline_cfg = SplineConfig::default();
|
||||||
|
let mut stroke_counter = manifest.next_stroke_id;
|
||||||
|
|
||||||
|
println!("[worker] Started writing loop in {:?}", dir);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Erzeuge neuen Strich mit einigen Samples
|
||||||
|
let base_x = (stroke_counter % 500) as f32;
|
||||||
|
let samples = vec![
|
||||||
|
StylusSample {
|
||||||
|
position: Vec2::new(base_x, 100.0),
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: 0.0,
|
||||||
|
},
|
||||||
|
StylusSample {
|
||||||
|
position: Vec2::new(base_x + 10.0, 150.0),
|
||||||
|
pressure: 0.7,
|
||||||
|
timestamp: 0.01,
|
||||||
|
},
|
||||||
|
StylusSample {
|
||||||
|
position: Vec2::new(base_x + 20.0, 200.0),
|
||||||
|
pressure: 0.6,
|
||||||
|
timestamp: 0.02,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let stroke = finalize_stroke_with_config(
|
||||||
|
stroke_counter,
|
||||||
|
&samples,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
&spline_cfg,
|
||||||
|
);
|
||||||
|
stroke_counter += 1;
|
||||||
|
manifest.next_stroke_id = stroke_counter;
|
||||||
|
|
||||||
|
let page = notebook.current_page_mut();
|
||||||
|
page.add_stroke(stroke);
|
||||||
|
|
||||||
|
let page_data = PageData::from(&*page);
|
||||||
|
|
||||||
|
// 1. Schreibe Seite atomar
|
||||||
|
if let Err(e) = save_page(dir, &page_data) {
|
||||||
|
eprintln!("[worker] Error saving page: {:?}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Aktualisiere Manifest atomar
|
||||||
|
manifest.sync_from_notebook(¬ebook);
|
||||||
|
if let Err(e) = save_manifest(dir, &manifest) {
|
||||||
|
eprintln!("[worker] Error saving manifest: {:?}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
|
||||||
|
if stroke_counter % 5 == 0 {
|
||||||
|
if let Err(e) = save_notebook(dir, ¬ebook, &mut manifest) {
|
||||||
|
eprintln!("[worker] Error saving notebook: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
|
||||||
|
if stroke_counter % 25 == 0 {
|
||||||
|
let page_idx = notebook.add_page(PageBackground::DotGrid);
|
||||||
|
notebook.current_page_index = page_idx;
|
||||||
|
let new_page_data = PageData::from(notebook.current_page());
|
||||||
|
if let Err(e) = save_page(dir, &new_page_data) {
|
||||||
|
eprintln!("[worker] Error saving new page: {:?}", e);
|
||||||
|
}
|
||||||
|
manifest.sync_from_notebook(¬ebook);
|
||||||
|
if let Err(e) = save_manifest(dir, &manifest) {
|
||||||
|
eprintln!("[worker] Error saving manifest: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("[verify] Checking notebook in {:?}", dir);
|
||||||
|
|
||||||
|
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
|
||||||
|
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
|
||||||
|
|
||||||
|
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
|
||||||
|
let manifest_path = dir.join("manifest.json");
|
||||||
|
let manifest_bak = dir.join("manifest.json.bak");
|
||||||
|
if !manifest_path.exists() && !manifest_bak.exists() {
|
||||||
|
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notizbuch laden
|
||||||
|
let (_notebook, manifest) = match load_notebook(dir) {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[verify] FAILED to load notebook: {:?}", e);
|
||||||
|
return Err(Box::new(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
|
||||||
|
manifest.title,
|
||||||
|
manifest.pages.len(),
|
||||||
|
manifest.next_stroke_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Alle im Manifest registrierten Seiten laden und prüfen
|
||||||
|
for page_meta in &manifest.pages {
|
||||||
|
match load_page(dir, page_meta.id) {
|
||||||
|
Ok(page_data) => {
|
||||||
|
let page = page_data.to_page();
|
||||||
|
println!(
|
||||||
|
"[verify] Page {} verified: {} strokes, background={:?}",
|
||||||
|
page_meta.id,
|
||||||
|
page.strokes.len(),
|
||||||
|
page.background
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[verify] FAILED to load page {} referenced by manifest: {:?}",
|
||||||
|
page_meta.id, e
|
||||||
|
);
|
||||||
|
return Err(Box::new(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prüfen ob Quarantäne-Dateien existieren
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name = file_name.to_string_lossy();
|
||||||
|
if name.contains(".corrupt-") {
|
||||||
|
println!("[verify] Notice: Found quarantined file: {}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.len() < 3 {
|
||||||
|
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = &args[1];
|
||||||
|
let notebook_dir = Path::new(&args[2]);
|
||||||
|
|
||||||
|
match command.as_str() {
|
||||||
|
"init" => match run_init(notebook_dir) {
|
||||||
|
Ok(_) => process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[init] Error: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worker" => {
|
||||||
|
run_worker(notebook_dir);
|
||||||
|
}
|
||||||
|
"verify" => match run_verify(notebook_dir) {
|
||||||
|
Ok(_) => process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[verify] Error: {}", e);
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
eprintln!("Unknown command: {}", other);
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+648
-54
@@ -6,15 +6,17 @@
|
|||||||
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
|
//! - NDC-Transformation der 2D-Vektor-Meshes über Vertex-Shader-Uniforms.
|
||||||
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
|
//! - Alpha-Blending (`wgpu::BlendState::ALPHA_BLENDING`).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use winit::dpi::PhysicalSize;
|
use winit::dpi::PhysicalSize;
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
|
|
||||||
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR};
|
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
|
||||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
|
use crate::viewport::PageStack;
|
||||||
|
|
||||||
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
|
/// 2D-Vertex für Inking-Meshes mit Positions- und Farbattributen (Multi-Color).
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -58,6 +60,8 @@ impl InkVertex {
|
|||||||
pub struct CameraUniforms {
|
pub struct CameraUniforms {
|
||||||
pub transform_scale: [f32; 2],
|
pub transform_scale: [f32; 2],
|
||||||
pub transform_offset: [f32; 2],
|
pub transform_offset: [f32; 2],
|
||||||
|
/// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung)
|
||||||
|
pub ink_params: [f32; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
|
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
|
||||||
@@ -87,13 +91,83 @@ pub fn spline_vertices_to_ndc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
||||||
|
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
|
||||||
|
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
|
||||||
|
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
|
||||||
|
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
|
||||||
|
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
|
||||||
|
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
|
||||||
|
|
||||||
|
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
|
||||||
|
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
|
||||||
|
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
|
||||||
|
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
|
||||||
|
let preferred: &[wgpu::PresentMode] = if force_fifo {
|
||||||
|
&[wgpu::PresentMode::Fifo]
|
||||||
|
} else {
|
||||||
|
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
|
||||||
|
};
|
||||||
|
preferred
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|m| available.contains(m))
|
||||||
|
.or_else(|| available.first().copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erkennt, ob die GPU-Daten einer Nachbarseite noch zur Seite passen. Änderungen an der
|
||||||
|
/// aktuellen Seite verwerfen deren Eintrag ohnehin (siehe [`CanvasState::sync_page`]).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct NeighborFingerprint {
|
||||||
|
page_id: u64,
|
||||||
|
background: PageBackground,
|
||||||
|
size: [u32; 2],
|
||||||
|
paper_theme: PaperTheme,
|
||||||
|
vertex_count: usize,
|
||||||
|
stroke_count: usize,
|
||||||
|
last_stroke_id: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NeighborFingerprint {
|
||||||
|
fn of(page: &Page, paper_theme: PaperTheme) -> Self {
|
||||||
|
Self {
|
||||||
|
page_id: page.id,
|
||||||
|
background: page.background,
|
||||||
|
size: page.size.to_array().map(f32::to_bits),
|
||||||
|
paper_theme,
|
||||||
|
vertex_count: page.vertex_cache.len(),
|
||||||
|
stroke_count: page.strokes.len(),
|
||||||
|
last_stroke_id: page.strokes.last().map(|s| s.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
|
||||||
|
struct NeighborPage {
|
||||||
|
fingerprint: NeighborFingerprint,
|
||||||
|
/// Ecke oben links in Koordinaten der aktuellen Seite
|
||||||
|
offset: glam::Vec2,
|
||||||
|
camera_buffer: wgpu::Buffer,
|
||||||
|
camera_bind_group: wgpu::BindGroup,
|
||||||
|
background_buffer: Option<wgpu::Buffer>,
|
||||||
|
background_vertex_count: u32,
|
||||||
|
vertex_buffer: Option<wgpu::Buffer>,
|
||||||
|
stroke_ranges: Vec<Range<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct CanvasState {
|
pub struct CanvasState {
|
||||||
pub surface: wgpu::Surface<'static>,
|
pub surface: wgpu::Surface<'static>,
|
||||||
|
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||||
|
adapter: wgpu::Adapter,
|
||||||
|
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||||
|
presentation_warned: bool,
|
||||||
pub device: wgpu::Device,
|
pub device: wgpu::Device,
|
||||||
pub queue: wgpu::Queue,
|
pub queue: wgpu::Queue,
|
||||||
pub config: wgpu::SurfaceConfiguration,
|
pub config: wgpu::SurfaceConfiguration,
|
||||||
pub size: PhysicalSize<u32>,
|
pub size: PhysicalSize<u32>,
|
||||||
pub viewport: PageViewport,
|
pub viewport: PageViewport,
|
||||||
|
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||||
|
ui_inset_left: f32,
|
||||||
|
ui_inset_right: f32,
|
||||||
pub camera: crate::viewport::ViewportState,
|
pub camera: crate::viewport::ViewportState,
|
||||||
|
|
||||||
// Pipelines
|
// Pipelines
|
||||||
@@ -104,6 +178,7 @@ pub struct CanvasState {
|
|||||||
pub camera_buffer: wgpu::Buffer,
|
pub camera_buffer: wgpu::Buffer,
|
||||||
pub camera_bind_group: wgpu::BindGroup,
|
pub camera_bind_group: wgpu::BindGroup,
|
||||||
pub current_ink_color: [f32; 4],
|
pub current_ink_color: [f32; 4],
|
||||||
|
pub current_stroke_width_mm: f32,
|
||||||
|
|
||||||
// Statische Hintergrund-Geometrie
|
// Statische Hintergrund-Geometrie
|
||||||
pub paper_buffer: wgpu::Buffer,
|
pub paper_buffer: wgpu::Buffer,
|
||||||
@@ -111,6 +186,20 @@ pub struct CanvasState {
|
|||||||
pub background_vertex_capacity: usize,
|
pub background_vertex_capacity: usize,
|
||||||
pub background_vertex_count: u32,
|
pub background_vertex_count: u32,
|
||||||
pub current_background: PageBackground,
|
pub current_background: PageBackground,
|
||||||
|
/// Papierfarbe der Anzeige
|
||||||
|
pub paper_theme: PaperTheme,
|
||||||
|
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||||
|
pub adapt_ink: bool,
|
||||||
|
/// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen
|
||||||
|
page_size: glam::Vec2,
|
||||||
|
fit_box: glam::Vec2,
|
||||||
|
/// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung)
|
||||||
|
text_specs: HashMap<String, TextSpec>,
|
||||||
|
/// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung
|
||||||
|
/// zeigt es)
|
||||||
|
hidden_text: Option<u64>,
|
||||||
|
/// Auflösung der Texttexturen in Pixeln je Seiteneinheit
|
||||||
|
text_resolution: f32,
|
||||||
|
|
||||||
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||||
pub live_vertex_buffer: wgpu::Buffer,
|
pub live_vertex_buffer: wgpu::Buffer,
|
||||||
@@ -128,6 +217,18 @@ pub struct CanvasState {
|
|||||||
pub committed_vertex_count: u32,
|
pub committed_vertex_count: u32,
|
||||||
pub committed_strokes: Vec<Range<u32>>,
|
pub committed_strokes: Vec<Range<u32>>,
|
||||||
|
|
||||||
|
// Eingefügte Bilder der aktuellen Seite
|
||||||
|
image_layer: crate::image_layer::ImageLayer,
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite
|
||||||
|
neighbors: Vec<NeighborPage>,
|
||||||
|
camera_layout: wgpu::BindGroupLayout,
|
||||||
|
|
||||||
|
/// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein)
|
||||||
|
pub ruler: Option<crate::ruler::Ruler>,
|
||||||
|
/// Kante, an der der aktuelle Live-Strich eingerastet ist
|
||||||
|
ruler_lock: Option<crate::ruler::EdgeLock>,
|
||||||
|
|
||||||
// egui UI-Renderer
|
// egui UI-Renderer
|
||||||
pub egui_renderer: egui_wgpu::Renderer,
|
pub egui_renderer: egui_wgpu::Renderer,
|
||||||
}
|
}
|
||||||
@@ -144,23 +245,47 @@ impl CanvasState {
|
|||||||
let viewport =
|
let viewport =
|
||||||
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
||||||
|
|
||||||
// 1. WGPU Instance & Surface
|
// 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
|
||||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||||
backends: wgpu::Backends::all(),
|
backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let surface = instance.create_surface(window.clone())?;
|
let surface = instance.create_surface(window.clone())?;
|
||||||
|
|
||||||
// 2. Adapter auswählen (hohe Performance bevorzugt)
|
// 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
|
||||||
let adapter = instance
|
// Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
|
||||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
// anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
|
||||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
let mut chosen = None;
|
||||||
compatible_surface: Some(&surface),
|
for force_fallback_adapter in [false, true] {
|
||||||
force_fallback_adapter: false,
|
let Some(adapter) = instance
|
||||||
})
|
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||||
.await
|
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||||
.ok_or("Kein passender GPU-Adapter gefunden")?;
|
compatible_surface: Some(&surface),
|
||||||
|
force_fallback_adapter,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let info = adapter.get_info();
|
||||||
|
let caps = surface.get_capabilities(&adapter);
|
||||||
|
match choose_present_mode(&caps.present_modes) {
|
||||||
|
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
|
||||||
|
eprintln!(
|
||||||
|
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
|
||||||
|
info.name, info.backend, mode
|
||||||
|
);
|
||||||
|
chosen = Some((adapter, caps, mode));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => eprintln!(
|
||||||
|
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
|
||||||
|
info.name, info.backend
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
|
||||||
|
|
||||||
// 3. Device & Queue erstellen
|
// 3. Device & Queue erstellen
|
||||||
let (device, queue) = adapter
|
let (device, queue) = adapter
|
||||||
@@ -174,8 +299,7 @@ impl CanvasState {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1
|
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
|
||||||
let surface_caps = surface.get_capabilities(&adapter);
|
|
||||||
let surface_format = surface_caps
|
let surface_format = surface_caps
|
||||||
.formats
|
.formats
|
||||||
.iter()
|
.iter()
|
||||||
@@ -183,20 +307,6 @@ impl CanvasState {
|
|||||||
.find(|f| f.is_srgb())
|
.find(|f| f.is_srgb())
|
||||||
.unwrap_or(surface_caps.formats[0]);
|
.unwrap_or(surface_caps.formats[0]);
|
||||||
|
|
||||||
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
|
||||||
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
|
||||||
wgpu::PresentMode::Fifo
|
|
||||||
} else if surface_caps
|
|
||||||
.present_modes
|
|
||||||
.contains(&wgpu::PresentMode::Mailbox)
|
|
||||||
{
|
|
||||||
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
|
||||||
wgpu::PresentMode::Mailbox
|
|
||||||
} else {
|
|
||||||
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
|
|
||||||
wgpu::PresentMode::Fifo
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = wgpu::SurfaceConfiguration {
|
let config = wgpu::SurfaceConfiguration {
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
format: surface_format,
|
format: surface_format,
|
||||||
@@ -235,6 +345,7 @@ impl CanvasState {
|
|||||||
let camera_uniforms = CameraUniforms {
|
let camera_uniforms = CameraUniforms {
|
||||||
transform_scale: scale,
|
transform_scale: scale,
|
||||||
transform_offset: offset,
|
transform_offset: offset,
|
||||||
|
ink_params: [0.0; 4],
|
||||||
};
|
};
|
||||||
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Camera Uniform Buffer"),
|
label: Some("Camera Uniform Buffer"),
|
||||||
@@ -271,7 +382,7 @@ impl CanvasState {
|
|||||||
},
|
},
|
||||||
fragment: Some(wgpu::FragmentState {
|
fragment: Some(wgpu::FragmentState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: "fs_main",
|
entry_point: "fs_ink",
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: config.format,
|
format: config.format,
|
||||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
@@ -325,7 +436,8 @@ impl CanvasState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 6. Statische Buffer
|
// 6. Statische Buffer
|
||||||
let paper_quad = generate_paper_quad();
|
let paper_theme = PaperTheme::default();
|
||||||
|
let paper_quad = generate_paper_quad(paper_theme.paper_color(), crate::document::A4_SIZE);
|
||||||
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Paper Quad Buffer"),
|
label: Some("Paper Quad Buffer"),
|
||||||
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -335,7 +447,11 @@ impl CanvasState {
|
|||||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||||
|
|
||||||
let current_background = PageBackground::Grid5mm;
|
let current_background = PageBackground::Grid5mm;
|
||||||
let bg_mesh = generate_background_mesh(current_background);
|
let bg_mesh = generate_background_mesh(
|
||||||
|
current_background,
|
||||||
|
paper_theme.pattern_color(),
|
||||||
|
crate::document::A4_SIZE,
|
||||||
|
);
|
||||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("Background Mesh Buffer"),
|
label: Some("Background Mesh Buffer"),
|
||||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||||
@@ -359,26 +475,45 @@ impl CanvasState {
|
|||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let image_layer = crate::image_layer::ImageLayer::new(
|
||||||
|
&device,
|
||||||
|
&shader,
|
||||||
|
&bind_group_layout,
|
||||||
|
config.format,
|
||||||
|
);
|
||||||
|
|
||||||
// 8. egui Renderer für das schwebende UI initialisieren
|
// 8. egui Renderer für das schwebende UI initialisieren
|
||||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface,
|
||||||
|
adapter,
|
||||||
|
presentation_warned: false,
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
config,
|
config,
|
||||||
size,
|
size,
|
||||||
viewport,
|
viewport,
|
||||||
|
ui_inset_left: 0.0,
|
||||||
|
ui_inset_right: 0.0,
|
||||||
strip_pipeline,
|
strip_pipeline,
|
||||||
triangle_pipeline,
|
triangle_pipeline,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
camera_bind_group,
|
camera_bind_group,
|
||||||
current_ink_color: default_ink_color,
|
current_ink_color: default_ink_color,
|
||||||
|
current_stroke_width_mm: 0.5,
|
||||||
paper_buffer,
|
paper_buffer,
|
||||||
background_buffer,
|
background_buffer,
|
||||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||||
background_vertex_count: bg_mesh.len() as u32,
|
background_vertex_count: bg_mesh.len() as u32,
|
||||||
current_background,
|
current_background,
|
||||||
|
paper_theme,
|
||||||
|
adapt_ink: false,
|
||||||
|
page_size: crate::document::A4_SIZE,
|
||||||
|
fit_box: crate::document::A4_SIZE,
|
||||||
|
text_specs: HashMap::new(),
|
||||||
|
hidden_text: None,
|
||||||
|
text_resolution: 1.0,
|
||||||
live_vertex_buffer,
|
live_vertex_buffer,
|
||||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||||
live_vertex_count: 0,
|
live_vertex_count: 0,
|
||||||
@@ -395,6 +530,11 @@ impl CanvasState {
|
|||||||
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
||||||
committed_vertex_count: 0,
|
committed_vertex_count: 0,
|
||||||
committed_strokes: Vec::with_capacity(128),
|
committed_strokes: Vec::with_capacity(128),
|
||||||
|
image_layer,
|
||||||
|
neighbors: Vec::new(),
|
||||||
|
camera_layout: bind_group_layout,
|
||||||
|
ruler: None,
|
||||||
|
ruler_lock: None,
|
||||||
egui_renderer,
|
egui_renderer,
|
||||||
camera: crate::viewport::ViewportState::new(),
|
camera: crate::viewport::ViewportState::new(),
|
||||||
})
|
})
|
||||||
@@ -402,16 +542,72 @@ impl CanvasState {
|
|||||||
|
|
||||||
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
|
/// Aktualisiert die 2D-Kamera Uniforms (Scale & Offset im NDC-Raum) basierend auf Viewport und Kamera.
|
||||||
pub fn update_uniforms(&mut self) {
|
pub fn update_uniforms(&mut self) {
|
||||||
let (scale, offset) = self.camera.ndc_transform(&self.viewport);
|
let camera_uniforms = self.camera_uniforms(glam::Vec2::ZERO);
|
||||||
let camera_uniforms = CameraUniforms {
|
|
||||||
transform_scale: scale,
|
|
||||||
transform_offset: offset,
|
|
||||||
};
|
|
||||||
self.queue.write_buffer(
|
self.queue.write_buffer(
|
||||||
&self.camera_buffer,
|
&self.camera_buffer,
|
||||||
0,
|
0,
|
||||||
bytemuck::cast_slice(&[camera_uniforms]),
|
bytemuck::cast_slice(&[camera_uniforms]),
|
||||||
);
|
);
|
||||||
|
for neighbor in &self.neighbors {
|
||||||
|
self.queue.write_buffer(
|
||||||
|
&neighbor.camera_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&[self.camera_uniforms(neighbor.offset)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kamera für eine Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite).
|
||||||
|
fn camera_uniforms(&self, origin: glam::Vec2) -> CameraUniforms {
|
||||||
|
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
|
||||||
|
offset[0] += origin.x * scale[0];
|
||||||
|
offset[1] += origin.y * scale[1];
|
||||||
|
CameraUniforms {
|
||||||
|
transform_scale: scale,
|
||||||
|
transform_offset: offset,
|
||||||
|
ink_params: [
|
||||||
|
if self.adapt_ink && self.paper_theme.is_dark() {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||||
|
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||||
|
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.ui_inset_left = left;
|
||||||
|
self.ui_inset_right = right;
|
||||||
|
self.recalculate_viewport();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rahmen, in den alle Seiten des Notizbuchs passen (bestimmt den Maßstab bei 100 %).
|
||||||
|
pub fn set_fit_box(&mut self, fit_box: glam::Vec2) {
|
||||||
|
if self.fit_box != fit_box {
|
||||||
|
self.fit_box = fit_box;
|
||||||
|
self.recalculate_viewport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recalculate_viewport(&mut self) {
|
||||||
|
self.viewport = PageViewport::calculate_for(
|
||||||
|
self.size.width as f32,
|
||||||
|
self.size.height as f32,
|
||||||
|
Self::VIEWPORT_MARGIN,
|
||||||
|
(self.ui_inset_left, self.ui_inset_right),
|
||||||
|
self.fit_box,
|
||||||
|
self.page_size,
|
||||||
|
);
|
||||||
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||||
@@ -420,15 +616,25 @@ impl CanvasState {
|
|||||||
self.size = new_size;
|
self.size = new_size;
|
||||||
self.config.width = new_size.width;
|
self.config.width = new_size.width;
|
||||||
self.config.height = new_size.height;
|
self.config.height = new_size.height;
|
||||||
|
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
||||||
|
let caps = self.surface.get_capabilities(&self.adapter);
|
||||||
|
if !caps.present_modes.contains(&self.config.present_mode) {
|
||||||
|
match choose_present_mode(&caps.present_modes) {
|
||||||
|
Some(mode) => self.config.present_mode = mode,
|
||||||
|
None => {
|
||||||
|
if !self.presentation_warned {
|
||||||
|
eprintln!(
|
||||||
|
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
|
||||||
|
NO_USABLE_GPU_MESSAGE
|
||||||
|
);
|
||||||
|
self.presentation_warned = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.recalculate_viewport();
|
||||||
self.viewport = PageViewport::calculate(
|
|
||||||
new_size.width as f32,
|
|
||||||
new_size.height as f32,
|
|
||||||
Self::VIEWPORT_MARGIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.update_uniforms();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +675,63 @@ impl CanvasState {
|
|||||||
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
|
self.camera.page_to_screen_vec2(page_pt, &self.viewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Seite unter dem Bildschirmpunkt, `None` neben den Seiten und
|
||||||
|
/// zwischen zwei Seiten.
|
||||||
|
pub fn page_at_screen(&self, stack: &PageStack, screen_x: f32, screen_y: f32) -> Option<usize> {
|
||||||
|
let p = self
|
||||||
|
.camera
|
||||||
|
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
|
||||||
|
let index = stack.page_at(p.y)?;
|
||||||
|
let x = p.x - stack.origins[index].x;
|
||||||
|
(0.0..=stack.sizes[index].x).contains(&x).then_some(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Seite auf Höhe der Fenstermitte.
|
||||||
|
pub fn page_at_view_center(&self, stack: &PageStack) -> Option<usize> {
|
||||||
|
let p = self.camera.screen_to_page_unclamped(
|
||||||
|
0.0,
|
||||||
|
self.size.height as f32 / 2.0,
|
||||||
|
&self.viewport,
|
||||||
|
);
|
||||||
|
stack.page_at(p.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite.
|
||||||
|
pub fn visible_neighbors(&self, stack: &PageStack) -> Vec<usize> {
|
||||||
|
let top = self
|
||||||
|
.camera
|
||||||
|
.screen_to_page_unclamped(0.0, 0.0, &self.viewport);
|
||||||
|
let bottom =
|
||||||
|
self.camera
|
||||||
|
.screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport);
|
||||||
|
stack.visible_neighbors(top.y, bottom.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
|
||||||
|
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seiten), damit
|
||||||
|
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
|
||||||
|
pub fn clamp_scroll(&mut self, stack: &PageStack) -> bool {
|
||||||
|
let center = [
|
||||||
|
self.viewport.page_x + self.viewport.page_width / 2.0,
|
||||||
|
self.size.height as f32 / 2.0,
|
||||||
|
];
|
||||||
|
let changed = self
|
||||||
|
.camera
|
||||||
|
.clamp_continuous_center(center, stack, &self.viewport);
|
||||||
|
if changed {
|
||||||
|
self.update_uniforms();
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
|
||||||
|
/// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
|
||||||
|
/// Seite mit [`Self::sync_page`] zu setzen.
|
||||||
|
pub fn shift_reference_page(&mut self, origin: glam::Vec2) {
|
||||||
|
self.camera.shift_reference_page(origin);
|
||||||
|
self.update_uniforms();
|
||||||
|
}
|
||||||
|
|
||||||
/// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold).
|
/// Ersetzt die Live-Vertices des aktiven Strichs durch ein bereinigtes Form-Mesh (für Draw-and-Hold).
|
||||||
pub fn set_live_shape_mesh(&mut self, samples: Vec<StylusSample>, vertices: Vec<InkVertex>) {
|
pub fn set_live_shape_mesh(&mut self, samples: Vec<StylusSample>, vertices: Vec<InkVertex>) {
|
||||||
self.live_samples = samples;
|
self.live_samples = samples;
|
||||||
@@ -502,10 +765,24 @@ impl CanvasState {
|
|||||||
|
|
||||||
/// Konfiguriert die Strichbreite in Millimetern.
|
/// Konfiguriert die Strichbreite in Millimetern.
|
||||||
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
|
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
|
||||||
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
|
self.current_stroke_width_mm = width_mm;
|
||||||
let base_radius = (width_mm * 5.0).max(0.25);
|
self.spline_config =
|
||||||
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
|
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||||
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
|
}
|
||||||
|
|
||||||
|
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
|
||||||
|
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
|
||||||
|
self.adapt_ink = adapt_ink;
|
||||||
|
if self.paper_theme != theme {
|
||||||
|
self.paper_theme = theme;
|
||||||
|
self.queue.write_buffer(
|
||||||
|
&self.paper_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color(), self.page_size)),
|
||||||
|
);
|
||||||
|
self.upload_background_mesh();
|
||||||
|
}
|
||||||
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||||
@@ -514,7 +791,16 @@ impl CanvasState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.current_background = background;
|
self.current_background = background;
|
||||||
let mesh = generate_background_mesh(background);
|
self.upload_background_mesh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||||
|
fn upload_background_mesh(&mut self) {
|
||||||
|
let mesh = generate_background_mesh(
|
||||||
|
self.current_background,
|
||||||
|
self.paper_theme.pattern_color(),
|
||||||
|
self.page_size,
|
||||||
|
);
|
||||||
self.background_vertex_count = mesh.len() as u32;
|
self.background_vertex_count = mesh.len() as u32;
|
||||||
|
|
||||||
if mesh.len() > self.background_vertex_capacity {
|
if mesh.len() > self.background_vertex_capacity {
|
||||||
@@ -536,7 +822,24 @@ impl CanvasState {
|
|||||||
/// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite.
|
/// Synchronisiert die GPU-Puffer mit den Daten der aktiven Seite.
|
||||||
pub fn sync_page(&mut self, page: &mut Page) {
|
pub fn sync_page(&mut self, page: &mut Page) {
|
||||||
page.rebuild_cache_if_dirty();
|
page.rebuild_cache_if_dirty();
|
||||||
|
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
|
||||||
|
self.neighbors.retain(|n| n.fingerprint.page_id != page.id);
|
||||||
|
if self.page_size != page.size {
|
||||||
|
self.page_size = page.size;
|
||||||
|
self.queue.write_buffer(
|
||||||
|
&self.paper_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&generate_paper_quad(
|
||||||
|
self.paper_theme.paper_color(),
|
||||||
|
self.page_size,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
self.upload_background_mesh();
|
||||||
|
self.recalculate_viewport();
|
||||||
|
}
|
||||||
self.set_background(page.background);
|
self.set_background(page.background);
|
||||||
|
let quads = self.page_image_quads(page, self.hidden_text);
|
||||||
|
self.image_layer.set_quads(quads);
|
||||||
|
|
||||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||||
self.committed_strokes = page.stroke_ranges.clone();
|
self.committed_strokes = page.stroke_ranges.clone();
|
||||||
@@ -563,6 +866,164 @@ impl CanvasState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: übernimmt die sichtbaren Nachbarseiten mit ihrem Versatz zur
|
||||||
|
/// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein
|
||||||
|
/// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen.
|
||||||
|
/// Eine leere Liste schaltet die Nachbarseiten ab.
|
||||||
|
pub fn set_neighbor_pages(&mut self, pages: &[(glam::Vec2, &Page)]) {
|
||||||
|
let mut old = std::mem::take(&mut self.neighbors);
|
||||||
|
let mut image_groups = Vec::with_capacity(pages.len());
|
||||||
|
for &(offset, page) in pages {
|
||||||
|
let fingerprint = NeighborFingerprint::of(page, self.paper_theme);
|
||||||
|
let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) {
|
||||||
|
Some(i) => old.swap_remove(i),
|
||||||
|
None => self.upload_neighbor(page, fingerprint),
|
||||||
|
};
|
||||||
|
neighbor.offset = offset;
|
||||||
|
self.queue.write_buffer(
|
||||||
|
&neighbor.camera_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&[self.camera_uniforms(offset)]),
|
||||||
|
);
|
||||||
|
self.neighbors.push(neighbor);
|
||||||
|
image_groups.push(self.page_image_quads(page, None));
|
||||||
|
}
|
||||||
|
self.image_layer.set_neighbor_quads(image_groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt Raster und Striche einer Nachbarseite auf die GPU.
|
||||||
|
fn upload_neighbor(&self, page: &Page, fingerprint: NeighborFingerprint) -> NeighborPage {
|
||||||
|
let camera_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("Neighbor Camera Uniform Buffer"),
|
||||||
|
size: std::mem::size_of::<CameraUniforms>() as wgpu::BufferAddress,
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let camera_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("Neighbor Camera Bind Group"),
|
||||||
|
layout: &self.camera_layout,
|
||||||
|
entries: &[wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: camera_buffer.as_entire_binding(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
|
||||||
|
(!vertices.is_empty()).then(|| {
|
||||||
|
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some(label),
|
||||||
|
size: std::mem::size_of_val(vertices) as u64,
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
self.queue
|
||||||
|
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
|
||||||
|
buffer
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
|
||||||
|
let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||||
|
background.extend(generate_background_mesh(
|
||||||
|
page.background,
|
||||||
|
self.paper_theme.pattern_color(),
|
||||||
|
page.size,
|
||||||
|
));
|
||||||
|
NeighborPage {
|
||||||
|
fingerprint,
|
||||||
|
offset: glam::Vec2::ZERO,
|
||||||
|
camera_buffer,
|
||||||
|
camera_bind_group,
|
||||||
|
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
|
||||||
|
background_vertex_count: background.len() as u32,
|
||||||
|
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||||
|
stroke_ranges: page.stroke_ranges.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen.
|
||||||
|
/// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts.
|
||||||
|
pub fn prepare_images(
|
||||||
|
&mut self,
|
||||||
|
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
|
||||||
|
) {
|
||||||
|
// Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen)
|
||||||
|
let resolution = text_resolution(self.viewport.scale * self.camera.zoom);
|
||||||
|
if resolution != self.text_resolution {
|
||||||
|
self.text_resolution = resolution;
|
||||||
|
self.image_layer
|
||||||
|
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX));
|
||||||
|
}
|
||||||
|
// Importierte PDF-Seiten schärfer rendern, sobald die Standardauflösung vergrößert würde
|
||||||
|
let page_long_edge = self.page_size.max_element() * self.viewport.scale * self.camera.zoom;
|
||||||
|
self.image_layer.set_pdf_pixels(
|
||||||
|
(page_long_edge > crate::pdf_doc::DISPLAY_RENDER_PIXELS as f32 * 1.25)
|
||||||
|
.then_some(crate::pdf_doc::ZOOMED_RENDER_PIXELS),
|
||||||
|
);
|
||||||
|
let specs = &self.text_specs;
|
||||||
|
self.image_layer
|
||||||
|
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) {
|
||||||
|
Some(spec) => crate::image_layer::TextureSource::Ready(crate::text::rasterize(
|
||||||
|
&spec.text,
|
||||||
|
spec.color,
|
||||||
|
resolution * spec.scale,
|
||||||
|
)),
|
||||||
|
None => load(key),
|
||||||
|
});
|
||||||
|
let layer = &self.image_layer;
|
||||||
|
self.text_specs.retain(|key, _| layer.uses(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blendet das gerade bearbeitete Textfeld aus (wirksam ab dem nächsten `sync_page`).
|
||||||
|
pub fn set_hidden_text(&mut self, id: Option<u64>) {
|
||||||
|
self.hidden_text = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bilder einer Seite für die Bildebene: zuerst die PDF-Seite, darüber die eingefügten
|
||||||
|
/// Bilder und Textfelder (außer `hidden`) in ihrer Reihenfolge.
|
||||||
|
fn page_image_quads(
|
||||||
|
&mut self,
|
||||||
|
page: &Page,
|
||||||
|
hidden: Option<u64>,
|
||||||
|
) -> Vec<(String, [glam::Vec2; 4])> {
|
||||||
|
let adapt = self.adapt_ink && self.paper_theme.is_dark();
|
||||||
|
let mut quads: Vec<_> = page
|
||||||
|
.pdf_background
|
||||||
|
.iter()
|
||||||
|
.map(|pdf| (pdf.key(), pdf.corners(page.size)))
|
||||||
|
.collect();
|
||||||
|
for stroke in &page.strokes {
|
||||||
|
let Some(corners) = stroke.object_corners() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(asset) = &stroke.image {
|
||||||
|
quads.push((asset.clone(), corners));
|
||||||
|
} else if let Some(text) = &stroke.text {
|
||||||
|
if Some(stroke.id) == hidden {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let color = if adapt {
|
||||||
|
crate::background::adapt_ink_color(stroke.color)
|
||||||
|
} else {
|
||||||
|
stroke.color
|
||||||
|
};
|
||||||
|
let key = text_key(text, color);
|
||||||
|
let scale = ((corners[1] - corners[0]).length() / text.wrap_width.max(text.size))
|
||||||
|
.max((corners[3] - corners[0]).length() / text.layout().height());
|
||||||
|
self.text_specs.entry(key.clone()).or_insert(TextSpec {
|
||||||
|
text: text.clone(),
|
||||||
|
color,
|
||||||
|
scale,
|
||||||
|
});
|
||||||
|
quads.push((key, corners));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quads
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
|
||||||
|
pub fn is_waiting_for_images(&self) -> bool {
|
||||||
|
self.image_layer.is_waiting()
|
||||||
|
}
|
||||||
|
|
||||||
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
|
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
|
||||||
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
|
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
|
||||||
self.filter.reset();
|
self.filter.reset();
|
||||||
@@ -571,6 +1032,13 @@ impl CanvasState {
|
|||||||
self.live_vertices.clear();
|
self.live_vertices.clear();
|
||||||
self.live_vertex_count = 0;
|
self.live_vertex_count = 0;
|
||||||
|
|
||||||
|
// Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr
|
||||||
|
let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0;
|
||||||
|
self.ruler_lock = self
|
||||||
|
.ruler
|
||||||
|
.and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width));
|
||||||
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
let pt = ControlPoint::new(fx, fy, fp);
|
let pt = ControlPoint::new(fx, fy, fp);
|
||||||
@@ -594,6 +1062,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Zwingend durch den 1€-Filter leiten
|
// 1. Zwingend durch den 1€-Filter leiten
|
||||||
|
let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y);
|
||||||
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp);
|
||||||
let new_pt = ControlPoint::new(fx, fy, fp);
|
let new_pt = ControlPoint::new(fx, fy, fp);
|
||||||
|
|
||||||
@@ -616,6 +1085,19 @@ impl CanvasState {
|
|||||||
self.update_live_mesh();
|
self.update_live_mesh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist.
|
||||||
|
fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) {
|
||||||
|
match self.ruler_lock {
|
||||||
|
Some(lock) => lock.project(glam::Vec2::new(x, y)).into(),
|
||||||
|
None => (x, y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist der aktuelle Live-Strich an einer Linealkante eingerastet?
|
||||||
|
pub fn is_ruler_locked(&self) -> bool {
|
||||||
|
self.ruler_lock.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
/// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück.
|
||||||
pub fn cancel_stroke(&mut self) {
|
pub fn cancel_stroke(&mut self) {
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
@@ -643,6 +1125,9 @@ impl CanvasState {
|
|||||||
self.current_ink_color,
|
self.current_ink_color,
|
||||||
&self.spline_config,
|
&self.spline_config,
|
||||||
);
|
);
|
||||||
|
let mut stroke = stroke;
|
||||||
|
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
||||||
|
stroke.finalized = true;
|
||||||
|
|
||||||
// Temporären GPU-Live-Buffer vollständig leeren
|
// Temporären GPU-Live-Buffer vollständig leeren
|
||||||
self.live_samples.clear();
|
self.live_samples.clear();
|
||||||
@@ -736,7 +1221,7 @@ impl CanvasState {
|
|||||||
view: &view,
|
view: &view,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund
|
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||||
store: wgpu::StoreOp::Store,
|
store: wgpu::StoreOp::Store,
|
||||||
},
|
},
|
||||||
})],
|
})],
|
||||||
@@ -745,10 +1230,30 @@ impl CanvasState {
|
|||||||
occlusion_query_set: None,
|
occlusion_query_set: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 0. Nachbarseiten der durchgehenden Ansicht (je mit eigener Kamera)
|
||||||
|
for (i, neighbor) in self.neighbors.iter().enumerate() {
|
||||||
|
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
|
||||||
|
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||||
|
if let Some(buffer) = &neighbor.background_buffer {
|
||||||
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
|
render_pass.draw(0..neighbor.background_vertex_count, 0..1);
|
||||||
|
}
|
||||||
|
self.image_layer.draw(&mut render_pass, i + 1);
|
||||||
|
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||||
|
render_pass.set_pipeline(&self.strip_pipeline);
|
||||||
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
|
for range in &neighbor.stroke_ranges {
|
||||||
|
if range.end > range.start {
|
||||||
|
render_pass.draw(range.clone(), 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||||
|
|
||||||
// 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
// 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||||
render_pass.draw(0..6, 0..1);
|
render_pass.draw(0..6, 0..1);
|
||||||
@@ -759,7 +1264,10 @@ impl CanvasState {
|
|||||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
// 3. Eingefügte Bilder (unter der Tinte)
|
||||||
|
self.image_layer.draw(&mut render_pass, 0);
|
||||||
|
|
||||||
|
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||||
render_pass.set_pipeline(&self.strip_pipeline);
|
render_pass.set_pipeline(&self.strip_pipeline);
|
||||||
|
|
||||||
if self.committed_vertex_count > 0 {
|
if self.committed_vertex_count > 0 {
|
||||||
@@ -771,7 +1279,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||||
if self.live_vertex_count > 0 {
|
if self.live_vertex_count > 0 {
|
||||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||||
@@ -821,10 +1329,63 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zu rasterndes Textfeld.
|
||||||
|
struct TextSpec {
|
||||||
|
text: crate::text::TextBox,
|
||||||
|
/// Angezeigte (ggf. aufgehellte) Farbe
|
||||||
|
color: [f32; 4],
|
||||||
|
/// Vergrößerung des Felds durch Skalieren der Auswahl
|
||||||
|
scale: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schlüssel der Texttexturen beginnen hiermit.
|
||||||
|
const TEXT_KEY_PREFIX: &str = "text:";
|
||||||
|
|
||||||
|
/// Texturschlüssel eines Textfelds: ändert sich mit Inhalt, Satz und Farbe.
|
||||||
|
fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
text.content.hash(&mut hasher);
|
||||||
|
text.font.hash(&mut hasher);
|
||||||
|
for v in [text.size, text.wrap_width].into_iter().chain(color) {
|
||||||
|
v.to_bits().hash(&mut hasher);
|
||||||
|
}
|
||||||
|
format!("{TEXT_KEY_PREFIX}{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit:
|
||||||
|
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 8). So wird nicht bei jedem Zoomschritt neu
|
||||||
|
/// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
|
||||||
|
/// verkleinert wirkt die dünne Schrift blass.
|
||||||
|
fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||||
|
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
|
||||||
|
2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_resolution_is_quantized_and_never_below_screen() {
|
||||||
|
for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] {
|
||||||
|
let r = text_resolution(px);
|
||||||
|
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}");
|
||||||
|
}
|
||||||
|
assert_eq!(text_resolution(0.27), text_resolution(0.28));
|
||||||
|
assert_eq!(text_resolution(100.0), 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_choose_present_mode() {
|
||||||
|
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||||
|
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
|
||||||
|
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
|
||||||
|
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
|
||||||
|
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
|
||||||
|
assert_eq!(choose_present_mode(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pixel_to_ndc_mapping() {
|
fn test_pixel_to_ndc_mapping() {
|
||||||
let width = 800.0;
|
let width = 800.0;
|
||||||
@@ -850,7 +1411,40 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_camera_uniforms() {
|
fn test_camera_uniforms() {
|
||||||
let _u = CameraUniforms::default();
|
let _u = CameraUniforms::default();
|
||||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16);
|
// Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4
|
||||||
|
assert_eq!(std::mem::size_of::<CameraUniforms>(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shader_is_valid_and_matches_uniforms() {
|
||||||
|
// Ein Shaderfehler fiele sonst erst beim Programmstart auf
|
||||||
|
let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl"))
|
||||||
|
.unwrap_or_else(|e| panic!("WGSL-Fehler: {e}"));
|
||||||
|
naga::valid::Validator::new(
|
||||||
|
naga::valid::ValidationFlags::all(),
|
||||||
|
naga::valid::Capabilities::empty(),
|
||||||
|
)
|
||||||
|
.validate(&module)
|
||||||
|
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
|
||||||
|
|
||||||
|
for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
|
||||||
|
assert!(
|
||||||
|
module.entry_points.iter().any(|ep| ep.name == name),
|
||||||
|
"Einstiegspunkt {name} fehlt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let camera = module
|
||||||
|
.types
|
||||||
|
.iter()
|
||||||
|
.find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms"))
|
||||||
|
.expect("CameraUniforms fehlt im Shader")
|
||||||
|
.1;
|
||||||
|
match camera.inner {
|
||||||
|
naga::TypeInner::Struct { span, .. } => {
|
||||||
|
assert_eq!(span as usize, std::mem::size_of::<CameraUniforms>())
|
||||||
|
}
|
||||||
|
ref other => panic!("CameraUniforms ist kein Struct: {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1092
-54
File diff suppressed because it is too large
Load Diff
+851
-108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
|||||||
|
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
|
||||||
|
//!
|
||||||
|
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||||
|
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||||
|
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::assets::is_supported_image_path;
|
||||||
|
|
||||||
|
/// Ein Eintrag der Dateiliste.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PickerEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Dateien die Auswahl anbietet.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PickerKind {
|
||||||
|
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||||
|
Image,
|
||||||
|
/// PDF zum Importieren als neues Notizbuch
|
||||||
|
Pdf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PickerKind {
|
||||||
|
/// Passt die Datei zu dieser Auswahl?
|
||||||
|
pub fn accepts(self, path: &Path) -> bool {
|
||||||
|
match self {
|
||||||
|
PickerKind::Image => is_supported_image_path(path),
|
||||||
|
PickerKind::Pdf => is_pdf_path(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist die Datei dem Namen nach ein PDF?
|
||||||
|
pub fn is_pdf_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zustand der Dateiauswahl.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct FilePicker {
|
||||||
|
pub kind: PickerKind,
|
||||||
|
pub dir: PathBuf,
|
||||||
|
pub entries: Vec<PickerEntry>,
|
||||||
|
/// Ordner nicht lesbar
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilePicker {
|
||||||
|
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner (Bilder) bzw. Downloads (PDF),
|
||||||
|
/// sonst im Home-Ordner.
|
||||||
|
pub fn open(kind: PickerKind, dir: Option<&Path>) -> Self {
|
||||||
|
let start = dir
|
||||||
|
.filter(|d| d.is_dir())
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.or_else(|| {
|
||||||
|
let preferred = match kind {
|
||||||
|
PickerKind::Image => "🖼 Bilder",
|
||||||
|
PickerKind::Pdf => "⬇ Downloads",
|
||||||
|
};
|
||||||
|
let all = shortcuts();
|
||||||
|
all.iter()
|
||||||
|
.find(|(label, _)| *label == preferred)
|
||||||
|
.or_else(|| all.last())
|
||||||
|
.map(|(_, p)| p.clone())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
let mut picker = Self {
|
||||||
|
kind,
|
||||||
|
dir: start.clone(),
|
||||||
|
entries: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
picker.navigate(&start);
|
||||||
|
picker
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechselt in `dir` und liest den Inhalt neu ein.
|
||||||
|
pub fn navigate(&mut self, dir: &Path) {
|
||||||
|
match list_dir(dir, self.kind) {
|
||||||
|
Ok(entries) => {
|
||||||
|
self.dir = dir.to_path_buf();
|
||||||
|
self.entries = entries;
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
self.error = Some(format!(
|
||||||
|
"'{}' kann nicht geöffnet werden: {err}",
|
||||||
|
dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Ebene nach oben.
|
||||||
|
pub fn go_up(&mut self) {
|
||||||
|
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
|
||||||
|
self.navigate(&parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordner und passende Dateien in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
|
||||||
|
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
|
||||||
|
pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry>> {
|
||||||
|
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if name.starts_with('.') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
|
||||||
|
let is_dir = path.is_dir();
|
||||||
|
(is_dir || kind.accepts(&path)).then_some(PickerEntry { name, path, is_dir })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_dir
|
||||||
|
.cmp(&a.is_dir)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
|
||||||
|
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
|
||||||
|
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||||
|
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let user_dirs =
|
||||||
|
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||||
|
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
|
||||||
|
parse_user_dir(&user_dirs, key, &home)
|
||||||
|
.into_iter()
|
||||||
|
.chain(fallbacks.iter().map(|f| home.join(f)))
|
||||||
|
.find(|p| p.is_dir() && *p != home)
|
||||||
|
};
|
||||||
|
let mut list = Vec::new();
|
||||||
|
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
|
||||||
|
list.push(("🖼 Bilder", p));
|
||||||
|
}
|
||||||
|
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
|
||||||
|
list.push(("⬇ Downloads", p));
|
||||||
|
}
|
||||||
|
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
|
||||||
|
list.push(("🖥 Schreibtisch", p));
|
||||||
|
}
|
||||||
|
list.push(("🏠 Persönlicher Ordner", home));
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
|
||||||
|
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
|
||||||
|
content.lines().find_map(|line| {
|
||||||
|
let value = line
|
||||||
|
.trim()
|
||||||
|
.strip_prefix(key)?
|
||||||
|
.trim_start()
|
||||||
|
.strip_prefix('=')?;
|
||||||
|
let value = value.trim().trim_matches('"');
|
||||||
|
let path = match value.strip_prefix("$HOME") {
|
||||||
|
Some(rest) => home.join(rest.trim_start_matches('/')),
|
||||||
|
None => PathBuf::from(value),
|
||||||
|
};
|
||||||
|
path.is_absolute().then_some(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_dir_sorts_and_filters() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
for name in [
|
||||||
|
"b.png",
|
||||||
|
"A.jpg",
|
||||||
|
"notiz.txt",
|
||||||
|
".versteckt.png",
|
||||||
|
"c.webp",
|
||||||
|
"Skript.PDF",
|
||||||
|
] {
|
||||||
|
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join(".cache")).unwrap();
|
||||||
|
|
||||||
|
let names = |kind| -> Vec<String> {
|
||||||
|
list_dir(dir.path(), kind)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.name)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
names(PickerKind::Image),
|
||||||
|
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
||||||
|
);
|
||||||
|
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_navigation_and_errors() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let sub = dir.path().join("Fotos");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
|
||||||
|
|
||||||
|
let mut picker = FilePicker::open(PickerKind::Image, Some(&sub));
|
||||||
|
assert_eq!(picker.dir, sub);
|
||||||
|
assert_eq!(picker.entries.len(), 1);
|
||||||
|
|
||||||
|
picker.go_up();
|
||||||
|
assert_eq!(picker.dir, dir.path());
|
||||||
|
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
|
||||||
|
|
||||||
|
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
|
||||||
|
picker.navigate(&dir.path().join("gibt-es-nicht"));
|
||||||
|
assert!(picker.error.is_some());
|
||||||
|
assert_eq!(picker.dir, dir.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_user_dirs() {
|
||||||
|
let home = Path::new("/home/anna");
|
||||||
|
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_user_dir(content, "XDG_PICTURES_DIR", home),
|
||||||
|
Some(PathBuf::from("/home/anna/Bilder"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_user_dir(content, "XDG_MUSIC_DIR", home),
|
||||||
|
Some(PathBuf::from("/daten/Musik"))
|
||||||
|
);
|
||||||
|
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1376
-145
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,13 @@ pub enum HistoryAction {
|
|||||||
before: Vec<Stroke>,
|
before: Vec<Stroke>,
|
||||||
after: Vec<Stroke>,
|
after: Vec<Stroke>,
|
||||||
},
|
},
|
||||||
|
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
|
||||||
|
SplitStrokes {
|
||||||
|
removed: Vec<Stroke>,
|
||||||
|
added: Vec<Stroke>,
|
||||||
|
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
|
||||||
|
at: std::time::Instant,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
|
/// History-Stack für Transaktionsverwaltung (Undo / Redo).
|
||||||
@@ -41,6 +48,40 @@ impl HistoryStack {
|
|||||||
self.redo_stack.clear();
|
self.redo_stack.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
|
||||||
|
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
|
||||||
|
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
|
||||||
|
/// wurden, verschwinden aus der Bilanz.
|
||||||
|
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
if let Some(HistoryAction::SplitStrokes {
|
||||||
|
removed: prev_removed,
|
||||||
|
added: prev_added,
|
||||||
|
at,
|
||||||
|
}) = self.undo_stack.last_mut()
|
||||||
|
{
|
||||||
|
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
|
||||||
|
&& self.redo_stack.is_empty()
|
||||||
|
{
|
||||||
|
for stroke in removed {
|
||||||
|
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
|
||||||
|
prev_added.remove(pos);
|
||||||
|
} else {
|
||||||
|
prev_removed.push(stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev_added.extend(added);
|
||||||
|
*at = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.push(HistoryAction::SplitStrokes {
|
||||||
|
removed,
|
||||||
|
added,
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Gibt zurück, ob Undo verfügbar ist.
|
/// Gibt zurück, ob Undo verfügbar ist.
|
||||||
pub fn can_undo(&self) -> bool {
|
pub fn can_undo(&self) -> bool {
|
||||||
!self.undo_stack.is_empty()
|
!self.undo_stack.is_empty()
|
||||||
@@ -95,6 +136,12 @@ impl HistoryStack {
|
|||||||
self.redo_stack
|
self.redo_stack
|
||||||
.push(HistoryAction::ModifyStrokes { before, after });
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
|
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||||
|
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
|
||||||
|
strokes.extend(removed.iter().cloned());
|
||||||
|
self.redo_stack
|
||||||
|
.push(HistoryAction::SplitStrokes { removed, added, at });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
@@ -140,6 +187,12 @@ impl HistoryStack {
|
|||||||
self.undo_stack
|
self.undo_stack
|
||||||
.push(HistoryAction::ModifyStrokes { before, after });
|
.push(HistoryAction::ModifyStrokes { before, after });
|
||||||
}
|
}
|
||||||
|
HistoryAction::SplitStrokes { removed, added, at } => {
|
||||||
|
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
|
||||||
|
strokes.extend(added.iter().cloned());
|
||||||
|
self.undo_stack
|
||||||
|
.push(HistoryAction::SplitStrokes { removed, added, at });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
|
||||||
|
//! Papier und vor der Tinte.
|
||||||
|
//!
|
||||||
|
//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte
|
||||||
|
//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim
|
||||||
|
//! Herauszoomen nicht flimmern.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::assets::RgbaImage;
|
||||||
|
|
||||||
|
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct ImageVertex {
|
||||||
|
pub position: [f32; 2],
|
||||||
|
pub uv: [f32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageVertex {
|
||||||
|
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
|
||||||
|
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
|
||||||
|
|
||||||
|
fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||||
|
wgpu::VertexBufferLayout {
|
||||||
|
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &Self::ATTRIBUTES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||||
|
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
||||||
|
let v = |i: usize, uv: [f32; 2]| ImageVertex {
|
||||||
|
position: corners[i].to_array(),
|
||||||
|
uv,
|
||||||
|
};
|
||||||
|
let (tl, tr, br, bl) = (
|
||||||
|
v(0, [0.0, 0.0]),
|
||||||
|
v(1, [1.0, 0.0]),
|
||||||
|
v(2, [1.0, 1.0]),
|
||||||
|
v(3, [0.0, 1.0]),
|
||||||
|
);
|
||||||
|
[tl, tr, bl, bl, tr, br]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
||||||
|
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
||||||
|
let mut levels = vec![base.clone()];
|
||||||
|
let Some(mut current) =
|
||||||
|
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
||||||
|
else {
|
||||||
|
return levels;
|
||||||
|
};
|
||||||
|
while current.width() > 1 || current.height() > 1 {
|
||||||
|
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
||||||
|
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
||||||
|
levels.push(RgbaImage {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
pixels: current.as_raw().clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
levels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||||
|
const MISSING: &str = "";
|
||||||
|
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
||||||
|
/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben.
|
||||||
|
const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||||
|
pub enum TextureSource {
|
||||||
|
Ready(RgbaImage),
|
||||||
|
/// Wird gerade erzeugt; später erneut fragen
|
||||||
|
Pending,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImageLayer {
|
||||||
|
pipeline: wgpu::RenderPipeline,
|
||||||
|
texture_layout: wgpu::BindGroupLayout,
|
||||||
|
sampler: wgpu::Sampler,
|
||||||
|
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
|
||||||
|
textures: HashMap<String, (wgpu::BindGroup, u64)>,
|
||||||
|
texture_bytes: u64,
|
||||||
|
/// Bilder je Seite (Gruppe 0: aktuelle Seite, danach die sichtbaren Nachbarseiten):
|
||||||
|
/// Asset-Name und Ecken in Koordinaten der jeweiligen Seite
|
||||||
|
groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
|
||||||
|
vertex_buffer: Option<wgpu::Buffer>,
|
||||||
|
vertices_dirty: bool,
|
||||||
|
/// Importierte PDF-Seiten in höherer Auflösung (längste Kante in Pixeln) anzeigen;
|
||||||
|
/// bis sie gerendert ist, bleibt die Standardauflösung sichtbar
|
||||||
|
pdf_pixels: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageLayer {
|
||||||
|
pub fn new(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
shader: &wgpu::ShaderModule,
|
||||||
|
camera_layout: &wgpu::BindGroupLayout,
|
||||||
|
format: wgpu::TextureFormat,
|
||||||
|
) -> Self {
|
||||||
|
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("Image Texture Bind Group Layout"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("Image Pipeline Layout"),
|
||||||
|
bind_group_layouts: &[camera_layout, &texture_layout],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("Image Render Pipeline"),
|
||||||
|
layout: Some(&layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: shader,
|
||||||
|
entry_point: "vs_image",
|
||||||
|
buffers: &[ImageVertex::desc()],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: shader,
|
||||||
|
entry_point: "fs_image",
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
});
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("Image Sampler"),
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::FilterMode::Linear,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pipeline,
|
||||||
|
texture_layout,
|
||||||
|
sampler,
|
||||||
|
textures: HashMap::new(),
|
||||||
|
texture_bytes: 0,
|
||||||
|
groups: vec![Vec::new()],
|
||||||
|
vertex_buffer: None,
|
||||||
|
vertices_dirty: false,
|
||||||
|
pdf_pixels: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
|
||||||
|
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
|
||||||
|
if quads != self.groups[0] {
|
||||||
|
self.groups[0] = quads;
|
||||||
|
self.vertices_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Übernimmt die Bilder der sichtbaren Nachbarseiten (Gruppen 1, 2, …).
|
||||||
|
pub fn set_neighbor_quads(&mut self, neighbors: Vec<Vec<(String, [glam::Vec2; 4])>>) {
|
||||||
|
if neighbors[..] != self.groups[1..] {
|
||||||
|
self.groups.truncate(1);
|
||||||
|
self.groups.extend(neighbors);
|
||||||
|
self.vertices_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_quads(&self) -> impl Iterator<Item = &(String, [glam::Vec2; 4])> {
|
||||||
|
self.groups.iter().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auflösung importierter PDF-Seiten (`None`: Standard), z. B. beim Hineinzoomen.
|
||||||
|
pub fn set_pdf_pixels(&mut self, pixels: Option<u32>) {
|
||||||
|
self.pdf_pixels = pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schlüssel der anzuzeigenden Textur: PDF-Seiten ggf. in höherer Auflösung.
|
||||||
|
fn texture_key(&self, key: &str) -> String {
|
||||||
|
match self.pdf_pixels {
|
||||||
|
Some(pixels) if key.contains('#') => format!("{key}@{pixels}"),
|
||||||
|
_ => key.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||||
|
/// ohne Änderungen kostet das nichts.
|
||||||
|
pub fn prepare(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
load: &mut dyn FnMut(&str) -> TextureSource,
|
||||||
|
) {
|
||||||
|
let missing: Vec<String> = self
|
||||||
|
.all_quads()
|
||||||
|
.map(|(key, _)| self.texture_key(key))
|
||||||
|
.filter(|key| !self.textures.contains_key(key))
|
||||||
|
.collect();
|
||||||
|
for key in missing {
|
||||||
|
if self.textures.contains_key(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (bind_group, bytes) = match load(&key) {
|
||||||
|
TextureSource::Ready(image) => (
|
||||||
|
self.upload_texture(device, queue, &key, &image),
|
||||||
|
image.pixels.len() as u64 * 4 / 3,
|
||||||
|
),
|
||||||
|
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||||
|
TextureSource::Pending => continue,
|
||||||
|
TextureSource::Failed(err) => {
|
||||||
|
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}");
|
||||||
|
(self.missing_texture(device, queue), 4)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.texture_bytes += bytes;
|
||||||
|
self.textures.insert(key, (bind_group, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
|
||||||
|
if self.texture_bytes > TEXTURE_CACHE_BYTES {
|
||||||
|
// Die Standardauflösung bleibt als Ersatz, bis die höhere fertig ist
|
||||||
|
let used: HashSet<String> = self
|
||||||
|
.all_quads()
|
||||||
|
.flat_map(|(key, _)| [key.clone(), self.texture_key(key)])
|
||||||
|
.collect();
|
||||||
|
self.textures.retain(|name, _| used.contains(name));
|
||||||
|
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.vertices_dirty {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.vertices_dirty = false;
|
||||||
|
let vertices: Vec<ImageVertex> = self
|
||||||
|
.all_quads()
|
||||||
|
.flat_map(|(_, corners)| quad_vertices(corners))
|
||||||
|
.collect();
|
||||||
|
if vertices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
|
||||||
|
let too_small = self
|
||||||
|
.vertex_buffer
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|b| b.size() < bytes.len() as u64);
|
||||||
|
if too_small {
|
||||||
|
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("Image Vertex Buffer"),
|
||||||
|
size: (bytes.len() as u64 * 2).max(1024),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(buffer) = &self.vertex_buffer {
|
||||||
|
queue.write_buffer(buffer, 0, bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wird die Textur `key` von einer sichtbaren Seite verwendet?
|
||||||
|
pub fn uses(&self, key: &str) -> bool {
|
||||||
|
self.all_quads().any(|(k, _)| k == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt alle Texturen frei, deren Schlüssel `matches` erfüllt (sie werden beim nächsten
|
||||||
|
/// `prepare` neu angefordert).
|
||||||
|
pub fn forget_textures(&mut self, mut matches: impl FnMut(&str) -> bool) {
|
||||||
|
self.textures.retain(|key, _| !matches(key));
|
||||||
|
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||||
|
pub fn is_waiting(&self) -> bool {
|
||||||
|
self.all_quads()
|
||||||
|
.any(|(key, _)| !self.textures.contains_key(&self.texture_key(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der
|
||||||
|
/// Seite) muss gesetzt sein.
|
||||||
|
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>, group: usize) {
|
||||||
|
let Some(buffer) = &self.vertex_buffer else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(quads) = self.groups.get(group) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if quads.is_empty() || self.vertices_dirty {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first: usize = self.groups[..group].iter().map(Vec::len).sum();
|
||||||
|
render_pass.set_pipeline(&self.pipeline);
|
||||||
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
|
for (i, (asset, _)) in quads.iter().enumerate() {
|
||||||
|
let Some((bind_group, _)) = self
|
||||||
|
.textures
|
||||||
|
.get(&self.texture_key(asset))
|
||||||
|
.or_else(|| self.textures.get(asset))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
render_pass.set_bind_group(1, bind_group, &[]);
|
||||||
|
let start = ((first + i) * 6) as u32;
|
||||||
|
render_pass.draw(start..start + 6, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
|
||||||
|
// Hellgraue Fläche statt eines unsichtbaren Bildes
|
||||||
|
let gray = RgbaImage {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
pixels: vec![200, 200, 200, 255],
|
||||||
|
};
|
||||||
|
self.upload_texture(device, queue, MISSING, &gray)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_texture(
|
||||||
|
&self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
label: &str,
|
||||||
|
image: &RgbaImage,
|
||||||
|
) -> wgpu::BindGroup {
|
||||||
|
let levels = mip_chain(image);
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some(label),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: levels.len() as u32,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
for (level, mip) in levels.iter().enumerate() {
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::ImageCopyTexture {
|
||||||
|
texture: &texture,
|
||||||
|
mip_level: level as u32,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&mip.pixels,
|
||||||
|
wgpu::ImageDataLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(4 * mip.width),
|
||||||
|
rows_per_image: Some(mip.height),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: mip.width,
|
||||||
|
height: mip.height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("Image Texture Bind Group"),
|
||||||
|
layout: &self.texture_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quad_vertices_map_corners_to_texture_corners() {
|
||||||
|
let corners = [
|
||||||
|
glam::Vec2::new(10.0, 10.0),
|
||||||
|
glam::Vec2::new(50.0, 10.0),
|
||||||
|
glam::Vec2::new(50.0, 30.0),
|
||||||
|
glam::Vec2::new(10.0, 30.0),
|
||||||
|
];
|
||||||
|
let quad = quad_vertices(&corners);
|
||||||
|
for v in quad {
|
||||||
|
// Texturkoordinate entspricht der relativen Lage im Rechteck
|
||||||
|
let u = (v.position[0] - 10.0) / 40.0;
|
||||||
|
let w = (v.position[1] - 10.0) / 20.0;
|
||||||
|
assert_eq!(v.uv, [u, w]);
|
||||||
|
}
|
||||||
|
// Beide Dreiecke decken alle vier Ecken ab
|
||||||
|
for c in corners {
|
||||||
|
assert!(quad.iter().any(|v| v.position == c.to_array()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mip_chain_halves_down_to_one_pixel() {
|
||||||
|
let base = RgbaImage {
|
||||||
|
width: 8,
|
||||||
|
height: 3,
|
||||||
|
pixels: vec![128; 8 * 3 * 4],
|
||||||
|
};
|
||||||
|
let levels = mip_chain(&base);
|
||||||
|
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
||||||
|
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||||
|
for l in &levels {
|
||||||
|
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -1,16 +1,26 @@
|
|||||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod file_picker;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod image_layer;
|
||||||
|
pub mod page_export;
|
||||||
|
pub mod pdf_doc;
|
||||||
|
pub mod pdf_merge;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod ruler;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod text;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
|
|||||||
+1691
-545
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
|||||||
|
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
|
||||||
|
//!
|
||||||
|
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
|
||||||
|
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
|
||||||
|
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
|
use hayro::hayro_syntax::Pdf;
|
||||||
|
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
|
||||||
|
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||||
|
|
||||||
|
use crate::assets::{AssetStore, RgbaImage};
|
||||||
|
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
|
||||||
|
use crate::export::{export_notebook_to_bytes, ExportStrategy};
|
||||||
|
|
||||||
|
/// Auflösung des PNG-Exports einer Seite.
|
||||||
|
pub const PAGE_PNG_DPI: f32 = 200.0;
|
||||||
|
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
|
||||||
|
pub const SELECTION_PNG_DPI: f32 = 150.0;
|
||||||
|
|
||||||
|
/// Hintergrund beim Bild-Export.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ImageBackground {
|
||||||
|
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
|
||||||
|
Paper,
|
||||||
|
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
|
||||||
|
Transparent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erzeugt ein einseitiges PDF aus `page`.
|
||||||
|
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
|
||||||
|
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
|
||||||
|
fn content_only(page: &Page) -> Page {
|
||||||
|
let mut page = page.clone();
|
||||||
|
page.background = PageBackground::Blank;
|
||||||
|
page.pdf_background = None;
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
|
||||||
|
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
|
||||||
|
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
||||||
|
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
||||||
|
let scale = dpi / 72.0;
|
||||||
|
let pixmap = hayro::render(
|
||||||
|
page,
|
||||||
|
&RenderCache::new(),
|
||||||
|
&InterpreterSettings::default(),
|
||||||
|
&RenderSettings::default(),
|
||||||
|
&PixmapSettings {
|
||||||
|
x_scale: scale,
|
||||||
|
y_scale: scale,
|
||||||
|
bg_color: if transparent { TRANSPARENT } else { WHITE },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut pixels = pixmap.data_as_u8_slice().to_vec();
|
||||||
|
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
|
||||||
|
for px in pixels.as_chunks_mut::<4>().0 {
|
||||||
|
let a = px[3] as u32;
|
||||||
|
if a > 0 && a < 255 {
|
||||||
|
for c in &mut px[..3] {
|
||||||
|
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RgbaImage {
|
||||||
|
width: pixmap.width() as u32,
|
||||||
|
height: pixmap.height() as u32,
|
||||||
|
pixels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kodiert ein Bild als PNG.
|
||||||
|
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
|
||||||
|
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgba8(buffer)
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
|
||||||
|
pub fn page_png(
|
||||||
|
page: &Page,
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
|
background: ImageBackground,
|
||||||
|
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let image = match background {
|
||||||
|
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
|
||||||
|
ImageBackground::Transparent => rasterize(
|
||||||
|
single_page_pdf(&content_only(page), assets)?,
|
||||||
|
PAGE_PNG_DPI,
|
||||||
|
true,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
encode_png(&image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
|
||||||
|
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> {
|
||||||
|
let pdf =
|
||||||
|
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
|
||||||
|
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
|
||||||
|
Ok(hayro_svg::convert(
|
||||||
|
pdf_page,
|
||||||
|
&hayro_svg::RenderCache::new(),
|
||||||
|
&InterpreterSettings::default(),
|
||||||
|
&hayro_svg::SvgRenderSettings {
|
||||||
|
bg_color: [255, 255, 255, 255],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
|
||||||
|
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
|
||||||
|
pub fn selection_image(
|
||||||
|
page: &Page,
|
||||||
|
ids: &[u64],
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
|
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
|
||||||
|
let mut selected = content_only(page);
|
||||||
|
selected.strokes.retain(|s| ids.contains(&s.id));
|
||||||
|
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
|
||||||
|
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
|
||||||
|
|
||||||
|
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?;
|
||||||
|
// Seiteneinheiten → Pixel
|
||||||
|
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
|
||||||
|
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
|
||||||
|
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
|
||||||
|
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
|
||||||
|
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
|
||||||
|
if x1 <= x0 || y1 <= y0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (w, h) = (x1 - x0, y1 - y0);
|
||||||
|
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
|
||||||
|
for y in y0..y1 {
|
||||||
|
let start = ((y * full.width + x0) * 4) as usize;
|
||||||
|
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
|
||||||
|
}
|
||||||
|
Ok(Some(RgbaImage {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
pixels,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
|
||||||
|
|
||||||
|
fn page_with_line() -> Page {
|
||||||
|
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||||
|
let samples: Vec<StylusSample> = (0..=20)
|
||||||
|
.map(|i| StylusSample {
|
||||||
|
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
|
||||||
|
pressure: 0.6,
|
||||||
|
timestamp: i as f64 * 0.01,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
[0.9, 0.1, 0.1, 1.0],
|
||||||
|
20.0,
|
||||||
|
Default::default(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
|
||||||
|
stroke.width = 20.0;
|
||||||
|
page.strokes.push(stroke);
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(png: &[u8]) -> image::RgbaImage {
|
||||||
|
image::load_from_memory(png).unwrap().into_rgba8()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_png_paper_and_transparent() {
|
||||||
|
let page = page_with_line();
|
||||||
|
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
|
||||||
|
// A4 mit 200 dpi
|
||||||
|
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
|
||||||
|
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
|
||||||
|
|
||||||
|
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
|
||||||
|
// Die Linie ist rot und deckend
|
||||||
|
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
|
||||||
|
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
|
||||||
|
assert!(
|
||||||
|
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
|
||||||
|
"{line:?}"
|
||||||
|
);
|
||||||
|
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_svg_contains_ink() {
|
||||||
|
let svg = page_svg(&page_with_line(), None).unwrap();
|
||||||
|
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
|
||||||
|
assert!(svg.contains("<path"));
|
||||||
|
assert!(svg.trim_end().ends_with("</svg>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begrenzungsrechteck (x0, y0, x1, y1) der deckenden Pixel.
|
||||||
|
fn ink_bounds(width: u32, pixels: &[u8]) -> (u32, u32, u32, u32) {
|
||||||
|
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||||||
|
for (i, p) in pixels.as_chunks::<4>().0.iter().enumerate() {
|
||||||
|
if p[3] > 128 {
|
||||||
|
let (x, y) = (i as u32 % width, i as u32 / width);
|
||||||
|
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_with_text() -> (Page, crate::text::TextBox) {
|
||||||
|
let text = crate::text::TextBox::new(
|
||||||
|
"Hallo Welt\nzweite Zeile".into(),
|
||||||
|
100.0,
|
||||||
|
crate::text::TextFont::Sans,
|
||||||
|
1200.0,
|
||||||
|
);
|
||||||
|
let size = glam::Vec2::new(1200.0, text.layout().height());
|
||||||
|
let corners = crate::text::frame_corners(
|
||||||
|
glam::Vec2::new(300.0, 400.0),
|
||||||
|
[glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||||
|
page.strokes.push(Stroke::new_text(
|
||||||
|
3,
|
||||||
|
text.clone(),
|
||||||
|
corners,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
));
|
||||||
|
(page, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exported_text_matches_screen_rendering() {
|
||||||
|
let (page, text) = page_with_text();
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||||
|
let exported = ink_bounds(png.width(), png.as_raw());
|
||||||
|
|
||||||
|
// Dieselbe Stelle, wie sie die Bildschirmtextur zeigt
|
||||||
|
let screen = crate::text::rasterize(&text, [0.0, 0.0, 0.0, 1.0], px_per_unit);
|
||||||
|
let local = ink_bounds(screen.width, &screen.pixels);
|
||||||
|
let (ox, oy) = ((300.0 * px_per_unit) as u32, (400.0 * px_per_unit) as u32);
|
||||||
|
let expected = (local.0 + ox, local.1 + oy, local.2 + ox, local.3 + oy);
|
||||||
|
for (a, b) in [
|
||||||
|
(exported.0, expected.0),
|
||||||
|
(exported.1, expected.1),
|
||||||
|
(exported.2, expected.2),
|
||||||
|
(exported.3, expected.3),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
a.abs_diff(b) <= 3,
|
||||||
|
"PDF {exported:?} ≠ Bildschirm {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exported_text_follows_rotation() {
|
||||||
|
let (mut page, _) = page_with_text();
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let straight = ink_bounds(png.width(), png.as_raw());
|
||||||
|
|
||||||
|
let center = page.strokes[0].aabb.min;
|
||||||
|
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let rotated = ink_bounds(png.width(), png.as_raw());
|
||||||
|
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt
|
||||||
|
let (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
|
||||||
|
let (rw, rh) = (rotated.2 - rotated.0, rotated.3 - rotated.1);
|
||||||
|
assert!(
|
||||||
|
rw.abs_diff(h) <= 4 && rh.abs_diff(w) <= 4,
|
||||||
|
"{straight:?} → {rotated:?}"
|
||||||
|
);
|
||||||
|
let px_per_unit = png.width() as f32 / PAGE_WIDTH;
|
||||||
|
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_landscape_page_export_keeps_positions() {
|
||||||
|
// Strich unten rechts auf einer Querformat-Seite: im PNG an derselben Stelle
|
||||||
|
let mut page = page_with_line();
|
||||||
|
page.size = glam::Vec2::new(2970.0, 1671.0);
|
||||||
|
page.strokes[0].translate(glam::Vec2::new(1900.0, 500.0)); // x 2400–2800, y 1500
|
||||||
|
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
|
||||||
|
let ratio = png.width() as f32 / png.height() as f32;
|
||||||
|
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
|
||||||
|
let px_per_unit = png.width() as f32 / 2970.0;
|
||||||
|
let at = |x: f32, y: f32| {
|
||||||
|
png.get_pixel((x * px_per_unit) as u32, (y * px_per_unit) as u32)
|
||||||
|
.0
|
||||||
|
};
|
||||||
|
assert!(at(2600.0, 1500.0)[3] > 200, "Strich liegt unten rechts");
|
||||||
|
assert!(at(2600.0, 1300.0)[3] == 0);
|
||||||
|
let svg = page_svg(&page, None).unwrap();
|
||||||
|
assert!(svg.contains("<path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_selection_image_is_cropped() {
|
||||||
|
let page = page_with_line();
|
||||||
|
let image = selection_image(&page, &[7], None).unwrap().unwrap();
|
||||||
|
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
|
||||||
|
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
|
||||||
|
assert!(
|
||||||
|
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
|
||||||
|
"{} px",
|
||||||
|
image.width
|
||||||
|
);
|
||||||
|
assert!(image.height < image.width / 3);
|
||||||
|
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
|
||||||
|
assert!(selection_image(&page, &[99], None).unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+325
@@ -0,0 +1,325 @@
|
|||||||
|
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||||
|
//!
|
||||||
|
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
||||||
|
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
|
||||||
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
|
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||||
|
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||||
|
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||||
|
|
||||||
|
use crate::assets::RgbaImage;
|
||||||
|
|
||||||
|
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
||||||
|
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
||||||
|
/// Längste Kante beim starken Hineinzoomen (A4 ≈ 440 dpi).
|
||||||
|
pub const ZOOMED_RENDER_PIXELS: u32 = 5120;
|
||||||
|
|
||||||
|
/// Texturschlüssel einer PDF-Seite: `<asset>#<seite>`, gerendert mit mehr als
|
||||||
|
/// [`DISPLAY_RENDER_PIXELS`] als `<asset>#<seite>@<pixel>`. Liefert Asset, Seite und Pixel.
|
||||||
|
pub fn parse_page_key(key: &str) -> Option<(&str, usize, u32)> {
|
||||||
|
let (asset, rest) = key.split_once('#')?;
|
||||||
|
let (page, pixels) = match rest.split_once('@') {
|
||||||
|
Some((page, pixels)) => (page, pixels.parse().ok()?),
|
||||||
|
None => (rest, DISPLAY_RENDER_PIXELS),
|
||||||
|
};
|
||||||
|
Some((asset, page.parse().ok()?, pixels))
|
||||||
|
}
|
||||||
|
/// Größte Seitenzahl, die importiert wird.
|
||||||
|
pub const MAX_IMPORT_PAGES: usize = 2000;
|
||||||
|
|
||||||
|
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
|
||||||
|
/// berücksichtigt).
|
||||||
|
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
||||||
|
let pdf = open(bytes)?;
|
||||||
|
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
||||||
|
if sizes.is_empty() {
|
||||||
|
return Err("Das PDF enthält keine Seiten".into());
|
||||||
|
}
|
||||||
|
if sizes.len() > MAX_IMPORT_PAGES {
|
||||||
|
return Err(format!(
|
||||||
|
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
|
||||||
|
sizes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(sizes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
||||||
|
Pdf::new(bytes).map_err(|e| match e {
|
||||||
|
LoadPdfError::Decryption(_) => {
|
||||||
|
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist,
|
||||||
|
/// auf weißem Grund.
|
||||||
|
pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage, String> {
|
||||||
|
let page = pdf
|
||||||
|
.pages()
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
|
||||||
|
let (w, h) = page.render_dimensions();
|
||||||
|
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
|
||||||
|
let pixmap = hayro::render(
|
||||||
|
page,
|
||||||
|
&RenderCache::new(),
|
||||||
|
&InterpreterSettings::default(),
|
||||||
|
&RenderSettings::default(),
|
||||||
|
&PixmapSettings {
|
||||||
|
x_scale: scale,
|
||||||
|
y_scale: scale,
|
||||||
|
bg_color: WHITE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe
|
||||||
|
Ok(RgbaImage {
|
||||||
|
width: pixmap.width() as u32,
|
||||||
|
height: pixmap.height() as u32,
|
||||||
|
pixels: pixmap.data_as_u8_slice().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild).
|
||||||
|
pub fn render_file_page(
|
||||||
|
path: &std::path::Path,
|
||||||
|
index: usize,
|
||||||
|
max_pixels: u32,
|
||||||
|
) -> Result<RgbaImage, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
render_page(&open(bytes)?, index, max_pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RenderRequest {
|
||||||
|
key: String,
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
max_pixels: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis eines Render-Auftrags.
|
||||||
|
pub type RenderResult = Result<RgbaImage, String>;
|
||||||
|
|
||||||
|
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||||
|
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||||
|
pub struct PdfRenderService {
|
||||||
|
requests: Sender<RenderRequest>,
|
||||||
|
results: Receiver<(String, RenderResult)>,
|
||||||
|
pending: HashSet<String>,
|
||||||
|
ready: HashMap<String, RenderResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfRenderService {
|
||||||
|
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||||
|
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
||||||
|
let (res_tx, res_rx) = mpsc::channel();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pdf-render".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||||
|
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
||||||
|
while let Ok(request) = req_rx.recv() {
|
||||||
|
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||||
|
open_pdf = std::fs::read(&request.path)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(open)
|
||||||
|
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
||||||
|
.ok()
|
||||||
|
.map(|pdf| (request.path.clone(), pdf));
|
||||||
|
}
|
||||||
|
let result = match &open_pdf {
|
||||||
|
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
||||||
|
None => Err("PDF kann nicht geöffnet werden".into()),
|
||||||
|
};
|
||||||
|
if res_tx.send((request.key, result)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
on_ready();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||||
|
Self {
|
||||||
|
requests: req_tx,
|
||||||
|
results: res_rx,
|
||||||
|
pending: HashSet::new(),
|
||||||
|
ready: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||||
|
/// [`Self::take`] liefert es später sofort.
|
||||||
|
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||||
|
self.collect();
|
||||||
|
if self.ready.contains_key(key) || self.pending.contains(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
||||||
|
if self.ready.len() >= 2 {
|
||||||
|
self.ready.clear();
|
||||||
|
}
|
||||||
|
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect(&mut self) {
|
||||||
|
while let Ok((done, result)) = self.results.try_recv() {
|
||||||
|
self.pending.remove(&done);
|
||||||
|
self.ready.insert(done, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||||
|
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
||||||
|
if !self.pending.insert(key.to_string()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let request = RenderRequest {
|
||||||
|
key: key.to_string(),
|
||||||
|
path,
|
||||||
|
page,
|
||||||
|
max_pixels,
|
||||||
|
};
|
||||||
|
if self.requests.send(request).is_err() {
|
||||||
|
self.pending.remove(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
|
||||||
|
/// Die Seite wird so gerendert, dass die längste Kante `max_pixels` lang ist.
|
||||||
|
pub fn take(
|
||||||
|
&mut self,
|
||||||
|
key: &str,
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
max_pixels: u32,
|
||||||
|
) -> Option<RenderResult> {
|
||||||
|
self.collect();
|
||||||
|
if let Some(result) = self.ready.remove(key) {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
if !self.request(key, path, page, max_pixels) {
|
||||||
|
return Some(Err("PDF-Render-Thread beendet".into()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
|
||||||
|
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
|
||||||
|
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
|
||||||
|
let mut objects: Vec<String> = Vec::new();
|
||||||
|
let n = pages.len();
|
||||||
|
// 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt
|
||||||
|
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
|
||||||
|
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
|
||||||
|
objects.push(format!(
|
||||||
|
"<< /Type /Pages /Kids [{}] /Count {n} >>",
|
||||||
|
kids.join(" ")
|
||||||
|
));
|
||||||
|
for (i, (w, h, rotate)) in pages.iter().enumerate() {
|
||||||
|
let content_id = 4 + 2 * i;
|
||||||
|
objects.push(format!(
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>"
|
||||||
|
));
|
||||||
|
let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0);
|
||||||
|
objects.push(format!(
|
||||||
|
"<< /Length {} >>\nstream\n{content}\nendstream",
|
||||||
|
content.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut out = b"%PDF-1.4\n".to_vec();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for (i, obj) in objects.iter().enumerate() {
|
||||||
|
offsets.push(out.len());
|
||||||
|
out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
|
||||||
|
}
|
||||||
|
let xref_pos = out.len();
|
||||||
|
out.extend_from_slice(
|
||||||
|
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
|
||||||
|
);
|
||||||
|
for off in offsets {
|
||||||
|
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(
|
||||||
|
format!(
|
||||||
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
|
||||||
|
objects.len() + 1
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_sizes_and_errors() {
|
||||||
|
let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]);
|
||||||
|
let sizes = page_sizes(pdf).unwrap();
|
||||||
|
assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]);
|
||||||
|
assert!(page_sizes(b"kein pdf".to_vec()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_page_draws_content() {
|
||||||
|
let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap();
|
||||||
|
let image = render_page(&pdf, 0, 400).unwrap();
|
||||||
|
assert_eq!((image.width, image.height), (400, 200));
|
||||||
|
let px = |x: u32, y: u32| {
|
||||||
|
let i = ((y * image.width + x) * 4) as usize;
|
||||||
|
&image.pixels[i..i + 4]
|
||||||
|
};
|
||||||
|
// Rotes Quadrat oben links, sonst weiß
|
||||||
|
assert_eq!(px(20, 20), &[255, 0, 0, 255]);
|
||||||
|
assert_eq!(px(300, 150), &[255, 255, 255, 255]);
|
||||||
|
assert!(render_page(&pdf, 5, 400).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_service_delivers_in_background() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("a.pdf");
|
||||||
|
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap();
|
||||||
|
let (wake_tx, wake_rx) = mpsc::channel();
|
||||||
|
let mut service = PdfRenderService::new(move || {
|
||||||
|
let _ = wake_tx.send(());
|
||||||
|
});
|
||||||
|
let px = DISPLAY_RENDER_PIXELS;
|
||||||
|
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||||
|
// Zweite Anfrage erteilt keinen weiteren Auftrag
|
||||||
|
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||||
|
wake_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.unwrap();
|
||||||
|
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||||
|
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||||
|
|
||||||
|
// Vorab rendern: danach sofort verfügbar
|
||||||
|
service.prefetch("a#0", path.clone(), 0);
|
||||||
|
wake_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.unwrap();
|
||||||
|
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_page_key() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_page_key("abc.pdf#3"),
|
||||||
|
Some(("abc.pdf", 3, DISPLAY_RENDER_PIXELS))
|
||||||
|
);
|
||||||
|
assert_eq!(parse_page_key("abc.pdf#3@5120"), Some(("abc.pdf", 3, 5120)));
|
||||||
|
assert_eq!(parse_page_key("abc.png"), None);
|
||||||
|
assert_eq!(parse_page_key("abc.pdf#x"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
|
||||||
|
//! Notizen (Text bleibt scharf und auswählbar).
|
||||||
|
//!
|
||||||
|
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
|
||||||
|
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
|
||||||
|
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
||||||
|
|
||||||
|
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Underlay {
|
||||||
|
/// Seitennummer im Ziel-PDF, ab 0
|
||||||
|
pub target_page: usize,
|
||||||
|
/// Schlüssel des Quell-PDFs in `sources`
|
||||||
|
pub source: String,
|
||||||
|
/// Seitennummer im Quell-PDF, ab 0
|
||||||
|
pub source_page: usize,
|
||||||
|
/// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links)
|
||||||
|
pub rect: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nicht übernommene Unterlagen samt Grund.
|
||||||
|
pub type FailedUnderlays = Vec<(Underlay, String)>;
|
||||||
|
|
||||||
|
/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
|
||||||
|
pub type Matrix = [f32; 6];
|
||||||
|
|
||||||
|
/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden.
|
||||||
|
pub fn compose(outer: Matrix, inner: Matrix) -> Matrix {
|
||||||
|
let [a1, b1, c1, d1, e1, f1] = outer;
|
||||||
|
let [a2, b2, c2, d2, e2, f2] = inner;
|
||||||
|
[
|
||||||
|
a1 * a2 + c1 * b2,
|
||||||
|
b1 * a2 + d1 * b2,
|
||||||
|
a1 * c2 + c1 * d2,
|
||||||
|
b1 * c2 + d1 * d2,
|
||||||
|
a1 * e2 + c1 * f2 + e1,
|
||||||
|
b1 * e2 + d1 * f2 + f1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad
|
||||||
|
/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst
|
||||||
|
/// in `rect` (links, unten, Breite, Höhe).
|
||||||
|
pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix {
|
||||||
|
let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3));
|
||||||
|
let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]];
|
||||||
|
let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) {
|
||||||
|
90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w),
|
||||||
|
180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h),
|
||||||
|
270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w),
|
||||||
|
_ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h),
|
||||||
|
};
|
||||||
|
let scale = (rect[2] / shown_w).min(rect[3] / shown_h);
|
||||||
|
let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]];
|
||||||
|
compose(place, compose(rotation, to_origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erbt ein Seitenattribut über die `Parent`-Kette.
|
||||||
|
fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> {
|
||||||
|
let mut node = page;
|
||||||
|
for _ in 0..64 {
|
||||||
|
if let Ok(value) = node.get(key) {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?;
|
||||||
|
node = doc.get_dictionary(parent).ok()?;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object {
|
||||||
|
doc.dereference(object).map(|(_, o)| o).unwrap_or(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> {
|
||||||
|
let array = resolve(doc, object?).as_array().ok()?;
|
||||||
|
let values: Vec<f32> = array
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| resolve(doc, v).as_float().ok())
|
||||||
|
.collect();
|
||||||
|
let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?;
|
||||||
|
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject.
|
||||||
|
/// Liefert das XObject und die Matrix für die Platzierung in `rect`.
|
||||||
|
fn page_as_form(
|
||||||
|
doc: &Document,
|
||||||
|
page_id: ObjectId,
|
||||||
|
rect: [f32; 4],
|
||||||
|
) -> Result<(Stream, Matrix), String> {
|
||||||
|
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
|
||||||
|
let media =
|
||||||
|
read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]);
|
||||||
|
let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) {
|
||||||
|
// Sichtbarer Bereich: Schnitt aus Crop- und Media-Box
|
||||||
|
Some(c) => [
|
||||||
|
c[0].max(media[0]),
|
||||||
|
c[1].max(media[1]),
|
||||||
|
c[2].min(media[2]),
|
||||||
|
c[3].min(media[3]),
|
||||||
|
],
|
||||||
|
None => media,
|
||||||
|
};
|
||||||
|
let rotate = inherited(doc, page, b"Rotate")
|
||||||
|
.and_then(|r| resolve(doc, r).as_i64().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let resources = inherited(doc, page, b"Resources")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
|
||||||
|
|
||||||
|
// Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen,
|
||||||
|
// mehrere werden entpackt und aneinandergehängt
|
||||||
|
let streams = doc.get_page_contents(page_id);
|
||||||
|
let (content, filter) = match streams.as_slice() {
|
||||||
|
[single] => {
|
||||||
|
let stream = doc
|
||||||
|
.get_object(*single)
|
||||||
|
.and_then(Object::as_stream)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let filter = ["Filter", "DecodeParms"]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone())))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(stream.content.clone(), filter)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut content = Vec::new();
|
||||||
|
for id in streams {
|
||||||
|
let stream = doc
|
||||||
|
.get_object(id)
|
||||||
|
.and_then(Object::as_stream)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let data = stream
|
||||||
|
.decompressed_content()
|
||||||
|
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
|
||||||
|
content.extend_from_slice(&data);
|
||||||
|
content.push(b'\n');
|
||||||
|
}
|
||||||
|
(content, Vec::new())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut dict = Dictionary::new();
|
||||||
|
dict.set("Type", Object::Name(b"XObject".to_vec()));
|
||||||
|
dict.set("Subtype", Object::Name(b"Form".to_vec()));
|
||||||
|
dict.set(
|
||||||
|
"BBox",
|
||||||
|
Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()),
|
||||||
|
);
|
||||||
|
dict.set("Resources", resources);
|
||||||
|
for (key, value) in filter {
|
||||||
|
dict.set(key, value);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
Stream::new(dict, content),
|
||||||
|
placement_matrix(bbox, rotate, rect),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes.
|
||||||
|
/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten
|
||||||
|
/// (z. B. verschlüsselte PDFs), samt Grund.
|
||||||
|
pub fn apply_underlays(
|
||||||
|
target_bytes: &[u8],
|
||||||
|
underlays: &[Underlay],
|
||||||
|
sources: &HashMap<String, Vec<u8>>,
|
||||||
|
) -> Result<(Vec<u8>, FailedUnderlays), String> {
|
||||||
|
let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?;
|
||||||
|
let target_pages: Vec<ObjectId> = target.get_pages().into_values().collect();
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
|
||||||
|
// Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen
|
||||||
|
let mut loaded: HashMap<&str, Result<BTreeMap<u32, ObjectId>, String>> = HashMap::new();
|
||||||
|
for underlay in underlays {
|
||||||
|
if loaded.contains_key(underlay.source.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let result = (|| {
|
||||||
|
let bytes = sources
|
||||||
|
.get(&underlay.source)
|
||||||
|
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
|
||||||
|
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
|
||||||
|
if source.is_encrypted() {
|
||||||
|
return Err("Quell-PDF ist verschlüsselt".to_string());
|
||||||
|
}
|
||||||
|
source.renumber_objects_with(target.max_id + 1);
|
||||||
|
let pages = source.get_pages();
|
||||||
|
target.max_id = target.max_id.max(source.max_id);
|
||||||
|
// Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern
|
||||||
|
// mit `prune_objects` entfernt
|
||||||
|
target.objects.extend(source.objects);
|
||||||
|
Ok(pages)
|
||||||
|
})();
|
||||||
|
loaded.insert(&underlay.source, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
for underlay in underlays {
|
||||||
|
let result = (|| {
|
||||||
|
let pages = loaded[underlay.source.as_str()]
|
||||||
|
.as_ref()
|
||||||
|
.map_err(Clone::clone)?;
|
||||||
|
let source_page = *pages
|
||||||
|
.get(&(underlay.source_page as u32 + 1))
|
||||||
|
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
|
||||||
|
let target_page = *target_pages
|
||||||
|
.get(underlay.target_page)
|
||||||
|
.ok_or_else(|| "Zielseite fehlt".to_string())?;
|
||||||
|
let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?;
|
||||||
|
let form_id = target.add_object(Object::Stream(form));
|
||||||
|
let name = format!("InkPdf{}", form_id.0);
|
||||||
|
add_xobject(&mut target, target_page, &name, form_id)?;
|
||||||
|
let ops = format!(
|
||||||
|
"q {} {} {} {} {} {} cm /{name} Do Q\n",
|
||||||
|
matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]
|
||||||
|
);
|
||||||
|
prepend_content(&mut target, target_page, ops.into_bytes())
|
||||||
|
})();
|
||||||
|
if let Err(err) = result {
|
||||||
|
failed.push((underlay.clone(), err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.prune_objects();
|
||||||
|
// Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert)
|
||||||
|
target.compress();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
target.save_to(&mut out).map_err(|e| e.to_string())?;
|
||||||
|
Ok((out, failed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trägt ein XObject in die Ressourcen der Seite ein.
|
||||||
|
fn add_xobject(
|
||||||
|
doc: &mut Document,
|
||||||
|
page_id: ObjectId,
|
||||||
|
name: &str,
|
||||||
|
id: ObjectId,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen)
|
||||||
|
let resources = {
|
||||||
|
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
|
||||||
|
match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) {
|
||||||
|
Some(Object::Dictionary(d)) => d.clone(),
|
||||||
|
_ => Dictionary::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) {
|
||||||
|
Ok(Object::Dictionary(d)) => d.clone(),
|
||||||
|
_ => Dictionary::new(),
|
||||||
|
};
|
||||||
|
xobjects.set(name, Object::Reference(id));
|
||||||
|
let mut resources = resources;
|
||||||
|
resources.set("XObject", Object::Dictionary(xobjects));
|
||||||
|
let page = doc
|
||||||
|
.get_object_mut(page_id)
|
||||||
|
.and_then(Object::as_dict_mut)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
page.set("Resources", Object::Dictionary(resources));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst).
|
||||||
|
fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec<u8>) -> Result<(), String> {
|
||||||
|
let existing: Vec<Object> = doc
|
||||||
|
.get_page_contents(page_id)
|
||||||
|
.into_iter()
|
||||||
|
.map(Object::Reference)
|
||||||
|
.collect();
|
||||||
|
let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content)));
|
||||||
|
let mut contents = vec![Object::Reference(id)];
|
||||||
|
contents.extend(existing);
|
||||||
|
let page = doc
|
||||||
|
.get_object_mut(page_id)
|
||||||
|
.and_then(Object::as_dict_mut)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
page.set("Contents", Object::Array(contents));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) {
|
||||||
|
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
|
||||||
|
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_placement_matrix() {
|
||||||
|
// Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20)
|
||||||
|
let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0)));
|
||||||
|
assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0)));
|
||||||
|
|
||||||
|
// Verschobene MediaBox
|
||||||
|
let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]);
|
||||||
|
assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0)));
|
||||||
|
|
||||||
|
// Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200):
|
||||||
|
// die linke untere Ecke landet links oben
|
||||||
|
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0)));
|
||||||
|
assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0)));
|
||||||
|
assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0)));
|
||||||
|
|
||||||
|
// 180° und 270° (= -90°)
|
||||||
|
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0)));
|
||||||
|
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_underlays_embeds_source_pages() {
|
||||||
|
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
|
||||||
|
let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]);
|
||||||
|
let sources = HashMap::from([("folien".to_string(), source)]);
|
||||||
|
let underlays = vec![
|
||||||
|
Underlay {
|
||||||
|
target_page: 0,
|
||||||
|
source: "folien".into(),
|
||||||
|
source_page: 0,
|
||||||
|
rect: [0.0, 421.0, 595.0, 421.0],
|
||||||
|
},
|
||||||
|
Underlay {
|
||||||
|
target_page: 1,
|
||||||
|
source: "folien".into(),
|
||||||
|
source_page: 5,
|
||||||
|
rect: [0.0, 0.0, 595.0, 842.0],
|
||||||
|
},
|
||||||
|
Underlay {
|
||||||
|
target_page: 1,
|
||||||
|
source: "fehlt".into(),
|
||||||
|
source_page: 0,
|
||||||
|
rect: [0.0, 0.0, 595.0, 842.0],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap();
|
||||||
|
// Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt
|
||||||
|
assert_eq!(failed.len(), 2);
|
||||||
|
|
||||||
|
let doc = Document::load_mem(&bytes).unwrap();
|
||||||
|
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
|
||||||
|
assert_eq!(
|
||||||
|
pages.len(),
|
||||||
|
2,
|
||||||
|
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
|
||||||
|
);
|
||||||
|
let first = doc.get_page_content(pages[0]).unwrap();
|
||||||
|
let text = String::from_utf8_lossy(&first);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("q "),
|
||||||
|
"Unterlage muss zuerst gezeichnet werden: {text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("Do Q"));
|
||||||
|
// Ursprünglicher Seiteninhalt bleibt erhalten
|
||||||
|
assert!(text.contains("re f"));
|
||||||
|
|
||||||
|
// Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen
|
||||||
|
let page = doc.get_dictionary(pages[0]).unwrap();
|
||||||
|
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
|
||||||
|
let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap();
|
||||||
|
let (_, form_ref) = xobjects.iter().next().unwrap();
|
||||||
|
let form = doc
|
||||||
|
.get_object(form_ref.as_reference().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.as_stream()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
form.dict.get(b"Subtype").unwrap().as_name().unwrap(),
|
||||||
|
b"Form"
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
|
||||||
|
//!
|
||||||
|
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
|
||||||
|
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
|
||||||
|
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
|
||||||
|
//! übersteuert die Wahl.
|
||||||
|
|
||||||
|
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DisplayBackend {
|
||||||
|
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
|
||||||
|
Automatic,
|
||||||
|
X11,
|
||||||
|
Wayland,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wählt das Anzeigesystem.
|
||||||
|
///
|
||||||
|
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
|
||||||
|
/// - `is_wsl`: läuft unter WSL
|
||||||
|
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
|
||||||
|
pub fn choose_display_backend(
|
||||||
|
override_value: Option<&str>,
|
||||||
|
is_wsl: bool,
|
||||||
|
has_x11_display: bool,
|
||||||
|
) -> DisplayBackend {
|
||||||
|
match override_value
|
||||||
|
.map(|v| v.trim().to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("x11") => return DisplayBackend::X11,
|
||||||
|
Some("wayland") => return DisplayBackend::Wayland,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if is_wsl && has_x11_display {
|
||||||
|
DisplayBackend::X11
|
||||||
|
} else {
|
||||||
|
DisplayBackend::Automatic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
|
||||||
|
pub fn is_wsl() -> bool {
|
||||||
|
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||||
|
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wahl anhand der aktuellen Umgebung.
|
||||||
|
pub fn display_backend_from_env() -> DisplayBackend {
|
||||||
|
choose_display_backend(
|
||||||
|
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
|
||||||
|
is_wsl(),
|
||||||
|
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_choose_display_backend() {
|
||||||
|
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(None, true, true),
|
||||||
|
DisplayBackend::X11
|
||||||
|
);
|
||||||
|
// WSL ohne X11-Display: winit entscheiden lassen
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(None, true, false),
|
||||||
|
DisplayBackend::Automatic
|
||||||
|
);
|
||||||
|
// Natives Linux: winit entscheiden lassen
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(None, false, true),
|
||||||
|
DisplayBackend::Automatic
|
||||||
|
);
|
||||||
|
// Übersteuerung
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(Some("wayland"), true, true),
|
||||||
|
DisplayBackend::Wayland
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(Some(" X11 "), false, true),
|
||||||
|
DisplayBackend::X11
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
choose_display_backend(Some("quatsch"), true, true),
|
||||||
|
DisplayBackend::X11
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe
|
||||||
|
//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien.
|
||||||
|
//!
|
||||||
|
//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm).
|
||||||
|
|
||||||
|
use glam::Vec2;
|
||||||
|
|
||||||
|
use crate::document::UNITS_PER_MM;
|
||||||
|
|
||||||
|
/// Länge des Lineals (26 cm).
|
||||||
|
pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM;
|
||||||
|
/// Breite des Lineals (3,2 cm).
|
||||||
|
pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM;
|
||||||
|
/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen).
|
||||||
|
pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM;
|
||||||
|
/// Radius des Dreh-Griffs.
|
||||||
|
pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM;
|
||||||
|
/// Winkel rasten in diesen Schritten ein …
|
||||||
|
pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0;
|
||||||
|
/// … wenn sie höchstens so weit davon entfernt sind.
|
||||||
|
pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0;
|
||||||
|
|
||||||
|
/// Lage des Lineals auf dem Blatt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Ruler {
|
||||||
|
/// Mittelpunkt
|
||||||
|
pub center: Vec2,
|
||||||
|
/// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht)
|
||||||
|
pub angle: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was an einer Stelle des Lineals liegt.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RulerHit {
|
||||||
|
/// Fläche zum Verschieben
|
||||||
|
Body,
|
||||||
|
/// Griff zum Drehen
|
||||||
|
RotateHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct EdgeLock {
|
||||||
|
pub origin: Vec2,
|
||||||
|
pub dir: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EdgeLock {
|
||||||
|
/// Fußpunkt von `p` auf der Geraden.
|
||||||
|
pub fn project(&self, p: Vec2) -> Vec2 {
|
||||||
|
self.origin + self.dir * (p - self.origin).dot(self.dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ruler {
|
||||||
|
pub fn new(center: Vec2) -> Self {
|
||||||
|
Self { center, angle: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheitsvektor entlang der Längsachse.
|
||||||
|
pub fn axis(&self) -> Vec2 {
|
||||||
|
Vec2::from_angle(self.angle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten).
|
||||||
|
pub fn normal(&self) -> Vec2 {
|
||||||
|
self.axis().perp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu).
|
||||||
|
pub fn local(&self, p: Vec2) -> Vec2 {
|
||||||
|
let d = p - self.center;
|
||||||
|
Vec2::new(d.dot(self.axis()), d.dot(self.normal()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°).
|
||||||
|
pub fn corners(&self) -> [Vec2; 4] {
|
||||||
|
let a = self.axis() * (RULER_LENGTH / 2.0);
|
||||||
|
let n = self.normal() * (RULER_WIDTH / 2.0);
|
||||||
|
[
|
||||||
|
self.center - a - n,
|
||||||
|
self.center + a - n,
|
||||||
|
self.center + a + n,
|
||||||
|
self.center - a + n,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende).
|
||||||
|
pub fn handle_center(&self) -> Vec2 {
|
||||||
|
self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen
|
||||||
|
/// eingerastete Striche.
|
||||||
|
pub fn hit(&self, p: Vec2) -> Option<RulerHit> {
|
||||||
|
if (p - self.handle_center()).length() <= HANDLE_RADIUS {
|
||||||
|
return Some(RulerHit::RotateHandle);
|
||||||
|
}
|
||||||
|
let l = self.local(p);
|
||||||
|
let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE;
|
||||||
|
(l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach
|
||||||
|
/// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter).
|
||||||
|
pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option<EdgeLock> {
|
||||||
|
let l = self.local(p);
|
||||||
|
if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let half = RULER_WIDTH / 2.0;
|
||||||
|
let side = if (l.y - half).abs() <= SNAP_DISTANCE {
|
||||||
|
1.0
|
||||||
|
} else if (l.y + half).abs() <= SNAP_DISTANCE {
|
||||||
|
-1.0
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(EdgeLock {
|
||||||
|
origin: self.center + self.normal() * side * (half + offset),
|
||||||
|
dir: self.axis(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von
|
||||||
|
/// 15° rasten ein.
|
||||||
|
pub fn rotate_toward(&mut self, toward: Vec2) {
|
||||||
|
let d = toward - self.center;
|
||||||
|
if d.length_squared() < 1e-6 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.angle = snap_angle(d.y.atan2(d.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung).
|
||||||
|
pub fn display_degrees(&self) -> u32 {
|
||||||
|
(self.angle.to_degrees().round() as i32).rem_euclid(180) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist.
|
||||||
|
pub fn snap_angle(angle: f32) -> f32 {
|
||||||
|
let deg = angle.to_degrees();
|
||||||
|
let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG;
|
||||||
|
if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG {
|
||||||
|
nearest.to_radians()
|
||||||
|
} else {
|
||||||
|
angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn close(a: Vec2, b: Vec2) -> bool {
|
||||||
|
(a - b).length() < 1e-2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_geometry_and_hits() {
|
||||||
|
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||||
|
let [tl, tr, br, bl] = ruler.corners();
|
||||||
|
assert!(close(
|
||||||
|
tl,
|
||||||
|
Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0)
|
||||||
|
));
|
||||||
|
assert!(close(
|
||||||
|
br,
|
||||||
|
Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||||
|
));
|
||||||
|
assert!(close(
|
||||||
|
tr - tl,
|
||||||
|
bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH)
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body));
|
||||||
|
assert_eq!(
|
||||||
|
ruler.hit(ruler.handle_center()),
|
||||||
|
Some(RulerHit::RotateHandle)
|
||||||
|
);
|
||||||
|
// Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet)
|
||||||
|
assert_eq!(
|
||||||
|
ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None);
|
||||||
|
assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edge_lock_projects_onto_offset_edge() {
|
||||||
|
let ruler = Ruler::new(Vec2::new(1000.0, 1000.0));
|
||||||
|
let edge_y = 1000.0 - RULER_WIDTH / 2.0;
|
||||||
|
// Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb
|
||||||
|
let lock = ruler
|
||||||
|
.edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0)
|
||||||
|
.unwrap();
|
||||||
|
let p = lock.project(Vec2::new(1300.0, edge_y - 55.0));
|
||||||
|
assert!(close(p, Vec2::new(1300.0, edge_y - 3.0)));
|
||||||
|
// Knapp innerhalb der unteren Kante
|
||||||
|
let lock = ruler
|
||||||
|
.edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0)
|
||||||
|
.unwrap();
|
||||||
|
assert!(close(
|
||||||
|
lock.project(Vec2::new(0.0, 0.0)),
|
||||||
|
Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0)
|
||||||
|
));
|
||||||
|
// Mitte oder weit weg: kein Einrasten
|
||||||
|
assert!(ruler.edge_lock(ruler.center, 0.0).is_none());
|
||||||
|
assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none());
|
||||||
|
assert!(ruler
|
||||||
|
.edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0)
|
||||||
|
.is_none());
|
||||||
|
|
||||||
|
// Gedrehtes Lineal: die Linie folgt der Drehung
|
||||||
|
let mut tilted = ruler;
|
||||||
|
tilted.angle = 30f32.to_radians();
|
||||||
|
let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0);
|
||||||
|
let lock = tilted.edge_lock(near_edge, 0.0).unwrap();
|
||||||
|
let a = lock.project(near_edge);
|
||||||
|
let b = lock.project(near_edge + Vec2::new(300.0, 0.0));
|
||||||
|
let dir = (b - a).normalize();
|
||||||
|
assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rotation_snaps_to_15_degrees() {
|
||||||
|
let mut ruler = Ruler::new(Vec2::ZERO);
|
||||||
|
ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0);
|
||||||
|
assert_eq!(ruler.display_degrees(), 45);
|
||||||
|
ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0);
|
||||||
|
assert_eq!(ruler.display_degrees(), 37);
|
||||||
|
// Gegenrichtung zeigt denselben Winkel
|
||||||
|
ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0);
|
||||||
|
assert_eq!(ruler.display_degrees(), 30);
|
||||||
|
assert_eq!(snap_angle(1.0f32.to_radians()), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-13
@@ -115,12 +115,15 @@ impl SelectionState {
|
|||||||
Page::selection_aabb(&strokes)
|
Page::selection_aabb(&strokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||||
|
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||||
|
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||||
pub fn hit_test_handles(
|
pub fn hit_test_handles(
|
||||||
&self,
|
&self,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
canvas: &CanvasState,
|
canvas: &CanvasState,
|
||||||
screen_pos: Vec2,
|
screen_pos: Vec2,
|
||||||
|
pixels_per_point: f32,
|
||||||
) -> SelectionHandleHit {
|
) -> SelectionHandleHit {
|
||||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||||
return SelectionHandleHit::None;
|
return SelectionHandleHit::None;
|
||||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
|||||||
let screen_center = (screen_min + screen_max) * 0.5;
|
let screen_center = (screen_min + screen_max) * 0.5;
|
||||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||||
|
|
||||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
let ppp = pixels_per_point;
|
||||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||||
|
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::RotateHandle {
|
return SelectionHandleHit::RotateHandle {
|
||||||
center_page: page_center,
|
center_page: page_center,
|
||||||
};
|
};
|
||||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::CornerHandle {
|
return SelectionHandleHit::CornerHandle {
|
||||||
corner_idx: i,
|
corner_idx: i,
|
||||||
fixed_corner_page: fixed_corners_page[i],
|
fixed_corner_page: fixed_corners_page[i],
|
||||||
@@ -239,7 +243,12 @@ impl SelectionState {
|
|||||||
if self.selected_ids.is_empty() {
|
if self.selected_ids.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let mut strokes = page.get_strokes(&self.selected_ids);
|
// Bilder behalten ihre Farben
|
||||||
|
let mut strokes: Vec<_> = page
|
||||||
|
.get_strokes(&self.selected_ids)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !s.is_image())
|
||||||
|
.collect();
|
||||||
if strokes.is_empty() {
|
if strokes.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -273,6 +282,16 @@ impl SelectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||||
|
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||||
|
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||||
|
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||||
|
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||||
|
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||||
|
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_selection_overlay(
|
pub fn render_selection_overlay(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
selection: &SelectionState,
|
selection: &SelectionState,
|
||||||
@@ -283,6 +302,8 @@ pub fn render_selection_overlay(
|
|||||||
egui::Order::Foreground,
|
egui::Order::Foreground,
|
||||||
egui::Id::new("selection_overlay_layer"),
|
egui::Id::new("selection_overlay_layer"),
|
||||||
));
|
));
|
||||||
|
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||||
|
let ppp = ctx.pixels_per_point();
|
||||||
|
|
||||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||||
if let DragMode::BoxSelect {
|
if let DragMode::BoxSelect {
|
||||||
@@ -290,8 +311,8 @@ pub fn render_selection_overlay(
|
|||||||
current_page,
|
current_page,
|
||||||
} = selection.drag_mode
|
} = selection.drag_mode
|
||||||
{
|
{
|
||||||
let p1 = canvas.page_to_screen(start_page);
|
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
|
||||||
let p2 = canvas.page_to_screen(current_page);
|
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
|
||||||
let min_x = p1[0].min(p2[0]);
|
let min_x = p1[0].min(p2[0]);
|
||||||
let min_y = p1[1].min(p2[1]);
|
let min_y = p1[1].min(p2[1]);
|
||||||
let max_x = p1[0].max(p2[0]);
|
let max_x = p1[0].max(p2[0]);
|
||||||
@@ -303,14 +324,14 @@ pub fn render_selection_overlay(
|
|||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||||
);
|
);
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Stroke::new(
|
egui::Stroke::new(
|
||||||
1.5_f32,
|
1.5_f32,
|
||||||
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
|
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -321,8 +342,8 @@ pub fn render_selection_overlay(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let p_min = canvas.page_to_screen(aabb.min);
|
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||||
let p_max = canvas.page_to_screen(aabb.max);
|
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||||
|
|
||||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||||
@@ -338,7 +359,7 @@ pub fn render_selection_overlay(
|
|||||||
|
|
||||||
// Dreh-Stängel (Stalk) und Drehgriff
|
// Dreh-Stängel (Stalk) und Drehgriff
|
||||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[top_center, rot_handle],
|
[top_center, rot_handle],
|
||||||
@@ -483,4 +504,30 @@ mod tests {
|
|||||||
let restored_pos = page.strokes[0].points[0].position;
|
let restored_pos = page.strokes[0].points[0].position;
|
||||||
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recolor_ignores_images() {
|
||||||
|
let mut page = Page::new(1, PageBackground::Blank);
|
||||||
|
let corners = [
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
Vec2::new(100.0, 0.0),
|
||||||
|
Vec2::new(100.0, 50.0),
|
||||||
|
Vec2::new(0.0, 50.0),
|
||||||
|
];
|
||||||
|
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
|
||||||
|
let undo_depth = page.history.undo_stack.len();
|
||||||
|
|
||||||
|
let mut sel = SelectionState::new();
|
||||||
|
sel.select_single(1);
|
||||||
|
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
|
||||||
|
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
|
||||||
|
assert_eq!(page.history.undo_stack.len(), undo_depth);
|
||||||
|
|
||||||
|
// Bild und Strich: nur der Strich wird umgefärbt
|
||||||
|
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
|
||||||
|
sel.select_multiple(vec![1, 2]);
|
||||||
|
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
|
||||||
|
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
|
||||||
|
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
|
||||||
|
//!
|
||||||
|
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::background::PaperTheme;
|
||||||
|
use crate::storage::{self, StorageError};
|
||||||
|
|
||||||
|
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum SidebarSide {
|
||||||
|
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
|
||||||
|
#[default]
|
||||||
|
Left,
|
||||||
|
/// Rechts (für Linkshänder)
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
|
||||||
|
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PressureCurve {
|
||||||
|
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
|
||||||
|
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
|
||||||
|
pub exponent: f32,
|
||||||
|
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
|
||||||
|
/// unterdrückt das Grundrauschen empfindlicher Stifte
|
||||||
|
pub threshold: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PressureCurve {
|
||||||
|
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
|
||||||
|
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
|
||||||
|
|
||||||
|
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
|
||||||
|
pub fn apply(&self, raw: f32) -> f32 {
|
||||||
|
let curve = self.sanitized();
|
||||||
|
if !raw.is_finite() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
|
||||||
|
normalized.powf(curve.exponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
|
||||||
|
/// gültigen Bereich.
|
||||||
|
pub fn sanitized(&self) -> Self {
|
||||||
|
let default = Self::default();
|
||||||
|
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
|
||||||
|
if v.is_finite() {
|
||||||
|
v.clamp(*range.start(), *range.end())
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
|
||||||
|
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PressureCurve {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
exponent: 1.0,
|
||||||
|
threshold: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UiSettings {
|
||||||
|
pub sidebar_side: SidebarSide,
|
||||||
|
/// Papierfarbe der Anzeige
|
||||||
|
pub paper_theme: PaperTheme,
|
||||||
|
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
|
||||||
|
pub adapt_ink_colors: bool,
|
||||||
|
/// Druckkennlinie des Stifts
|
||||||
|
pub pressure_curve: PressureCurve,
|
||||||
|
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
|
||||||
|
pub continuous_scroll: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UiSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sidebar_side: SidebarSide::default(),
|
||||||
|
paper_theme: PaperTheme::default(),
|
||||||
|
adapt_ink_colors: true,
|
||||||
|
pressure_curve: PressureCurve::default(),
|
||||||
|
continuous_scroll: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiSettings {
|
||||||
|
/// Standardpfad gemäß XDG-Spezifikation.
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||||
|
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||||
|
};
|
||||||
|
Some(base.join("ink-notes").join("ui.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||||
|
pub fn load_from(path: &Path) -> Self {
|
||||||
|
let mut settings: Self = match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
};
|
||||||
|
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert die Einstellungen atomar.
|
||||||
|
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||||
|
storage::atomic_write_json(path, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_round_trip_and_fallbacks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("ink-notes").join("ui.json");
|
||||||
|
|
||||||
|
// Fehlende Datei -> Standard (links)
|
||||||
|
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
|
||||||
|
|
||||||
|
let settings = UiSettings {
|
||||||
|
sidebar_side: SidebarSide::Right,
|
||||||
|
paper_theme: PaperTheme::Black,
|
||||||
|
adapt_ink_colors: false,
|
||||||
|
pressure_curve: PressureCurve {
|
||||||
|
exponent: 1.8,
|
||||||
|
threshold: 0.1,
|
||||||
|
},
|
||||||
|
continuous_scroll: false,
|
||||||
|
};
|
||||||
|
settings.save_to(&path).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|
||||||
|
// Beschädigte Datei -> Standard statt Absturz
|
||||||
|
std::fs::write(&path, b"{ kaputt").unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
|
||||||
|
// Unbekannte / fehlende Felder werden toleriert
|
||||||
|
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_old_settings_file_keeps_new_defaults() {
|
||||||
|
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("ui.json");
|
||||||
|
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
|
||||||
|
let settings = UiSettings::load_from(&path);
|
||||||
|
assert_eq!(settings.sidebar_side, SidebarSide::Right);
|
||||||
|
assert_eq!(settings.paper_theme, PaperTheme::White);
|
||||||
|
assert!(settings.adapt_ink_colors);
|
||||||
|
assert_eq!(settings.pressure_curve, PressureCurve::default());
|
||||||
|
assert!(settings.continuous_scroll);
|
||||||
|
|
||||||
|
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||||
|
assert_eq!(curve.exponent, 3.0);
|
||||||
|
assert_eq!(curve.threshold, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pressure_curve() {
|
||||||
|
// Standard: unverändert
|
||||||
|
let linear = PressureCurve::default();
|
||||||
|
for p in [0.0, 0.25, 0.5, 1.0] {
|
||||||
|
assert!((linear.apply(p) - p).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
|
||||||
|
let soft = PressureCurve {
|
||||||
|
exponent: 0.5,
|
||||||
|
threshold: 0.0,
|
||||||
|
};
|
||||||
|
let hard = PressureCurve {
|
||||||
|
exponent: 2.0,
|
||||||
|
threshold: 0.0,
|
||||||
|
};
|
||||||
|
assert!(soft.apply(0.5) > 0.6);
|
||||||
|
assert!(hard.apply(0.5) < 0.3);
|
||||||
|
// Endpunkte bleiben fest
|
||||||
|
for curve in [soft, hard] {
|
||||||
|
assert_eq!(curve.apply(0.0), 0.0);
|
||||||
|
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
|
||||||
|
let gated = PressureCurve {
|
||||||
|
exponent: 1.0,
|
||||||
|
threshold: 0.2,
|
||||||
|
};
|
||||||
|
assert_eq!(gated.apply(0.15), 0.0);
|
||||||
|
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
|
||||||
|
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
|
||||||
|
// Monoton steigend
|
||||||
|
let mut last = -1.0;
|
||||||
|
for i in 0..=100 {
|
||||||
|
let v = hard.apply(i as f32 / 100.0);
|
||||||
|
assert!(v >= last);
|
||||||
|
last = v;
|
||||||
|
}
|
||||||
|
// Ungültige Eingaben ergeben nie NaN
|
||||||
|
assert_eq!(linear.apply(f32::NAN), 0.0);
|
||||||
|
assert_eq!(linear.apply(7.0), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ struct VertexOutput {
|
|||||||
struct CameraUniforms {
|
struct CameraUniforms {
|
||||||
transform_scale: vec2<f32>,
|
transform_scale: vec2<f32>,
|
||||||
transform_offset: vec2<f32>,
|
transform_offset: vec2<f32>,
|
||||||
|
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
|
||||||
|
ink_params: vec4<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@group(0) @binding(0)
|
@group(0) @binding(0)
|
||||||
@@ -32,3 +34,46 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
|||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
return in.color;
|
return in.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
||||||
|
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||||
|
@fragment
|
||||||
|
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
if (camera.ink_params.x < 0.5) {
|
||||||
|
return in.color;
|
||||||
|
}
|
||||||
|
let max_c = max(max(in.color.r, in.color.g), in.color.b);
|
||||||
|
let min_c = min(min(in.color.r, in.color.g), in.color.b);
|
||||||
|
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||||
|
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
||||||
|
struct ImageVertexInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ImageVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(1) @binding(0)
|
||||||
|
var image_texture: texture_2d<f32>;
|
||||||
|
@group(1) @binding(1)
|
||||||
|
var image_sampler: sampler;
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
||||||
|
var out: ImageVertexOutput;
|
||||||
|
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||||
|
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||||
|
out.uv = in.uv;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
return textureSample(image_texture, image_sampler, in.uv);
|
||||||
|
}
|
||||||
|
|||||||
+16
-1
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SplineConfig {
|
||||||
|
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
|
||||||
|
pub fn from_width_units(width_units: f32) -> Self {
|
||||||
|
let base_radius = (width_units / 2.0).max(0.25);
|
||||||
|
Self {
|
||||||
|
min_radius: (base_radius * 0.5).max(0.15),
|
||||||
|
max_radius: (base_radius * 1.5).max(0.35),
|
||||||
|
samples_per_segment: 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
|
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
|
||||||
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
|
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
|
||||||
let p = pressure.clamp(0.0, 1.0);
|
let p = pressure.clamp(0.0, 1.0);
|
||||||
@@ -653,7 +665,10 @@ pub fn finalize_stroke_with_config(
|
|||||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Stroke::new(stroke_id, decimated, ink_vertices, color)
|
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
|
||||||
|
stroke.width = config.min_radius + config.max_radius;
|
||||||
|
stroke.finalized = true;
|
||||||
|
stroke
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+1984
-100
File diff suppressed because it is too large
Load Diff
+546
@@ -0,0 +1,546 @@
|
|||||||
|
//! Textfelder (Text-Werkzeug): Datenmodell, Zeilenumbruch und Rasterung.
|
||||||
|
//!
|
||||||
|
//! Ein Textfeld ist ein Objekt mit vier Ecken wie ein Bild (siehe `Stroke::text`). Der Text
|
||||||
|
//! wird in einem eigenen Koordinatensystem gesetzt – Breite = Umbruchbreite, Höhe = Zeilen ×
|
||||||
|
//! Zeilenhöhe, in Seiteneinheiten – und dieses Rechteck auf die Ecken abgebildet. So wirken
|
||||||
|
//! Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle auf Text.
|
||||||
|
//!
|
||||||
|
//! Bildschirm (Textur), PDF-Export (eingebettete Schrift, durchsuchbar) und Bearbeitung (egui)
|
||||||
|
//! verwenden dieselben Schriften aus egui und denselben Zeilenumbruch.
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use ab_glyph::{Font, FontRef, GlyphId, PxScale};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::assets::RgbaImage;
|
||||||
|
use crate::document::{Stroke, UNITS_PER_MM};
|
||||||
|
|
||||||
|
/// Schriftart eines Textfelds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||||
|
pub enum TextFont {
|
||||||
|
/// Proportionalschrift (Ubuntu Light) für Überschriften und Notizen
|
||||||
|
#[default]
|
||||||
|
Sans,
|
||||||
|
/// Festbreitenschrift (Hack) für Code
|
||||||
|
Mono,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextFont {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TextFont::Sans => "Normal",
|
||||||
|
TextFont::Mono => "Code",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name der Schrift in den egui-Standardschriften.
|
||||||
|
fn egui_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TextFont::Sans => "Ubuntu-Light",
|
||||||
|
TextFont::Mono => "Hack",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// egui-Schriftfamilie, deren erste Schrift diese ist (für die Bearbeitung).
|
||||||
|
pub fn egui_family(self) -> egui::FontFamily {
|
||||||
|
match self {
|
||||||
|
TextFont::Sans => egui::FontFamily::Proportional,
|
||||||
|
TextFont::Mono => egui::FontFamily::Monospace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TrueType-Daten der Schrift (aus den in egui eingebauten Schriften).
|
||||||
|
pub fn bytes(self) -> &'static [u8] {
|
||||||
|
static DEFINITIONS: OnceLock<egui::FontDefinitions> = OnceLock::new();
|
||||||
|
let definitions = DEFINITIONS.get_or_init(egui::FontDefinitions::default);
|
||||||
|
&definitions.font_data[self.egui_name()].font
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn face(self) -> &'static FontRef<'static> {
|
||||||
|
static SANS: OnceLock<FontRef<'static>> = OnceLock::new();
|
||||||
|
static MONO: OnceLock<FontRef<'static>> = OnceLock::new();
|
||||||
|
let cell = match self {
|
||||||
|
TextFont::Sans => &SANS,
|
||||||
|
TextFont::Mono => &MONO,
|
||||||
|
};
|
||||||
|
cell.get_or_init(|| {
|
||||||
|
FontRef::try_from_slice(self.bytes()).expect("eingebaute egui-Schrift ist gültig")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn units_per_em(self) -> f32 {
|
||||||
|
self.face().units_per_em().unwrap_or(1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Höhe von Ober- bis Unterlänge je Em: ab_glyph skaliert auf diese Höhe, PDF, egui und
|
||||||
|
/// dieses Modul messen die Schriftgröße als Em.
|
||||||
|
pub fn height_per_em(self) -> f32 {
|
||||||
|
let face = self.face();
|
||||||
|
(face.ascent_unscaled() - face.descent_unscaled()) / self.units_per_em()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seiteneinheiten je typografischem Punkt.
|
||||||
|
pub const UNITS_PER_PT: f32 = 25.4 / 72.0 * UNITS_PER_MM;
|
||||||
|
/// Wählbare Schriftgrößen in Punkt.
|
||||||
|
pub const TEXT_SIZES_PT: [f32; 7] = [8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0];
|
||||||
|
pub const DEFAULT_TEXT_SIZE_PT: f32 = 12.0;
|
||||||
|
/// Kleinste Umbruchbreite eines neuen Textfelds (3 cm).
|
||||||
|
pub const MIN_WRAP_WIDTH: f32 = 300.0;
|
||||||
|
/// Abstand neuer Textfelder zum rechten Blattrand (1 cm).
|
||||||
|
pub const RIGHT_MARGIN: f32 = 100.0;
|
||||||
|
/// Ein Tabulator wird durch so viele Leerzeichen ersetzt.
|
||||||
|
const TAB_WIDTH: usize = 4;
|
||||||
|
|
||||||
|
/// Inhalt und Satz eines Textfelds.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TextBox {
|
||||||
|
pub content: String,
|
||||||
|
/// Schriftgröße (Em) in Seiteneinheiten
|
||||||
|
pub size: f32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub font: TextFont,
|
||||||
|
/// Umbruchbreite in Seiteneinheiten (Breite des ungedrehten, unskalierten Felds)
|
||||||
|
pub wrap_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine gesetzte Zeile: Zeichen (ohne Zeichen, die die Schrift nicht kennt) und ihre
|
||||||
|
/// Glyphen mit x-Position.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct LaidLine {
|
||||||
|
pub text: String,
|
||||||
|
pub glyphs: Vec<(GlyphId, f32)>,
|
||||||
|
pub width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TextLayout {
|
||||||
|
pub lines: Vec<LaidLine>,
|
||||||
|
/// Oberlänge (Abstand Zeilenoberkante → Grundlinie)
|
||||||
|
pub ascent: f32,
|
||||||
|
pub line_height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextLayout {
|
||||||
|
/// Höhe des Felds; ein leeres Feld ist eine Zeile hoch.
|
||||||
|
pub fn height(&self) -> f32 {
|
||||||
|
self.lines.len().max(1) as f32 * self.line_height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grundlinie der Zeile `i` (von der Oberkante des Felds).
|
||||||
|
pub fn baseline(&self, i: usize) -> f32 {
|
||||||
|
i as f32 * self.line_height + self.ascent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBox {
|
||||||
|
pub fn new(content: String, size: f32, font: TextFont, wrap_width: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
content,
|
||||||
|
size,
|
||||||
|
font,
|
||||||
|
wrap_width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Größe des ungedrehten, unskalierten Felds in Seiteneinheiten.
|
||||||
|
pub fn box_size(&self) -> glam::Vec2 {
|
||||||
|
glam::Vec2::new(self.wrap_width.max(self.size), self.layout().height())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt den Text mit Zeilenumbruch an Leerzeichen; zu lange Wörter werden zeichenweise
|
||||||
|
/// umbrochen, Zeilenumbrüche im Text bleiben erhalten.
|
||||||
|
pub fn layout(&self) -> TextLayout {
|
||||||
|
let face = self.font.face();
|
||||||
|
let em = self.size / self.font.units_per_em();
|
||||||
|
let line_height =
|
||||||
|
(face.ascent_unscaled() - face.descent_unscaled() + face.line_gap_unscaled()) * em;
|
||||||
|
let width = self.wrap_width.max(self.size);
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for paragraph in self.content.split('\n') {
|
||||||
|
let mut chars: Vec<(char, GlyphId, f32)> = paragraph
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| {
|
||||||
|
let (c, n) = if c == '\t' { (' ', TAB_WIDTH) } else { (c, 1) };
|
||||||
|
std::iter::repeat_n(c, n)
|
||||||
|
})
|
||||||
|
.filter_map(|c| {
|
||||||
|
let id = face.glyph_id(c);
|
||||||
|
(id.0 != 0).then(|| (c, id, face.h_advance_unscaled(id) * em))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Unterschneidung (wie egui) dem vorderen Zeichen des Paars zurechnen
|
||||||
|
for i in 1..chars.len() {
|
||||||
|
chars[i - 1].2 += face.kern_unscaled(chars[i - 1].1, chars[i].1) * em;
|
||||||
|
}
|
||||||
|
let mut start = 0;
|
||||||
|
let mut x = 0.0;
|
||||||
|
// Letztes Leerzeichen der Zeile (möglicher Umbruch)
|
||||||
|
let mut last_space: Option<usize> = None;
|
||||||
|
for i in 0..chars.len() {
|
||||||
|
let (c, _, advance) = chars[i];
|
||||||
|
if c != ' ' && x + advance > width && i > start {
|
||||||
|
let end = match last_space {
|
||||||
|
Some(space) if space > start => space,
|
||||||
|
_ => i,
|
||||||
|
};
|
||||||
|
lines.push(laid_line(&chars[start..end]));
|
||||||
|
start = end;
|
||||||
|
while start < i && chars[start].0 == ' ' {
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
x = chars[start..i].iter().map(|g| g.2).sum();
|
||||||
|
last_space = None;
|
||||||
|
}
|
||||||
|
x += advance;
|
||||||
|
if c == ' ' {
|
||||||
|
last_space = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(laid_line(&chars[start..]));
|
||||||
|
}
|
||||||
|
// Leere Zeilen am Ende zählen mit (Platz für den Cursor), ein leerer Text ist eine Zeile
|
||||||
|
TextLayout {
|
||||||
|
lines,
|
||||||
|
ascent: face.ascent_unscaled() * em,
|
||||||
|
line_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn laid_line(chars: &[(char, GlyphId, f32)]) -> LaidLine {
|
||||||
|
let mut x = 0.0;
|
||||||
|
let mut glyphs = Vec::with_capacity(chars.len());
|
||||||
|
let mut width: f32 = 0.0;
|
||||||
|
for &(c, id, advance) in chars {
|
||||||
|
glyphs.push((id, x));
|
||||||
|
x += advance;
|
||||||
|
if c != ' ' {
|
||||||
|
width = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaidLine {
|
||||||
|
text: chars.iter().map(|g| g.0).collect(),
|
||||||
|
glyphs,
|
||||||
|
width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ecken eines Felds der Größe `size` mit Ursprung (oben links) `origin` und den Achsen
|
||||||
|
/// `axes` (Seitenvektor je Einheit nach rechts bzw. unten): oben links, oben rechts,
|
||||||
|
/// unten rechts, unten links.
|
||||||
|
pub fn frame_corners(
|
||||||
|
origin: glam::Vec2,
|
||||||
|
axes: [glam::Vec2; 2],
|
||||||
|
size: glam::Vec2,
|
||||||
|
) -> [glam::Vec2; 4] {
|
||||||
|
let right = axes[0] * size.x;
|
||||||
|
let down = axes[1] * size.y;
|
||||||
|
[origin, origin + right, origin + right + down, origin + down]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ursprung und Achsen (je Einheit) eines Felds der Größe `size` mit den Ecken `corners`.
|
||||||
|
pub fn frame_axes(corners: &[glam::Vec2; 4], size: glam::Vec2) -> (glam::Vec2, [glam::Vec2; 2]) {
|
||||||
|
let [tl, tr, _, bl] = *corners;
|
||||||
|
(
|
||||||
|
tl,
|
||||||
|
[(tr - tl) / size.x.max(1e-3), (bl - tl) / size.y.max(1e-3)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laufende Bearbeitung eines Textfelds (neu oder vorhanden).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct TextEditing {
|
||||||
|
/// Seite, auf der das Feld liegt
|
||||||
|
pub page_id: u64,
|
||||||
|
/// Bearbeitetes vorhandenes Feld im Zustand vor der Bearbeitung; `None` bei einem neuen
|
||||||
|
pub original: Option<Stroke>,
|
||||||
|
/// Ecke oben links und Achsen (Seitenvektor je Einheit nach rechts bzw. unten)
|
||||||
|
pub origin: glam::Vec2,
|
||||||
|
pub axes: [glam::Vec2; 2],
|
||||||
|
pub text: TextBox,
|
||||||
|
pub color: [f32; 4],
|
||||||
|
/// Beim nächsten Bild den Tastaturfokus auf das Feld setzen
|
||||||
|
pub focus: bool,
|
||||||
|
/// Bearbeitung beenden (Esc)
|
||||||
|
pub finish: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextEditing {
|
||||||
|
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
|
||||||
|
/// Rand eines Blatts der Breite `page_width` (mindestens 3 cm breit).
|
||||||
|
pub fn new_at(
|
||||||
|
page_id: u64,
|
||||||
|
pos: glam::Vec2,
|
||||||
|
page_width: f32,
|
||||||
|
size: f32,
|
||||||
|
font: TextFont,
|
||||||
|
color: [f32; 4],
|
||||||
|
) -> Self {
|
||||||
|
let right = page_width - RIGHT_MARGIN;
|
||||||
|
let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0);
|
||||||
|
let text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH));
|
||||||
|
let line_height = text.layout().line_height;
|
||||||
|
Self {
|
||||||
|
page_id,
|
||||||
|
original: None,
|
||||||
|
origin: glam::Vec2::new(x, (pos.y - line_height / 2.0).max(0.0)),
|
||||||
|
axes: [glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
text,
|
||||||
|
color,
|
||||||
|
focus: true,
|
||||||
|
finish: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bearbeitung des vorhandenen Textfelds `stroke`.
|
||||||
|
pub fn of_stroke(page_id: u64, stroke: &Stroke) -> Option<Self> {
|
||||||
|
let text = stroke.text.clone()?;
|
||||||
|
let corners = stroke.object_corners()?;
|
||||||
|
let (origin, axes) = frame_axes(&corners, text.box_size());
|
||||||
|
Some(Self {
|
||||||
|
page_id,
|
||||||
|
original: Some(stroke.clone()),
|
||||||
|
origin,
|
||||||
|
axes,
|
||||||
|
text,
|
||||||
|
color: stroke.color,
|
||||||
|
focus: true,
|
||||||
|
finish: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wurde gegenüber dem Ausgangszustand etwas geändert?
|
||||||
|
pub fn is_changed(&self) -> bool {
|
||||||
|
match &self.original {
|
||||||
|
Some(original) => {
|
||||||
|
original.text.as_ref() != Some(&self.text) || original.color != self.color
|
||||||
|
}
|
||||||
|
None => !self.is_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enthält das Feld nur Leerraum?
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.text.content.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis als Textfeld mit der ID `id`; die Höhe passt sich dem Text an, Lage,
|
||||||
|
/// Drehung und Skalierung bleiben. `None` für ein leeres Feld.
|
||||||
|
pub fn to_stroke(&self, id: u64) -> Option<Stroke> {
|
||||||
|
if self.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let corners = frame_corners(self.origin, self.axes, self.text.box_size());
|
||||||
|
Some(Stroke::new_text(id, self.text.clone(), corners, self.color))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exponent für die Kantendeckung der Glyphen (wie egui, damit Bearbeitung und Anzeige
|
||||||
|
/// gleich kräftig wirken).
|
||||||
|
const COVERAGE_GAMMA: f32 = 0.55;
|
||||||
|
|
||||||
|
/// Größte Kantenlänge einer Texttextur in Pixeln.
|
||||||
|
const MAX_TEXTURE_SIDE: f32 = 4096.0;
|
||||||
|
/// Größte Pixelzahl einer Texttextur (64 MB RGBA).
|
||||||
|
const MAX_TEXTURE_PIXELS: f32 = 16.0 * 1024.0 * 1024.0;
|
||||||
|
|
||||||
|
/// Rastert ein Textfeld mit `px_per_unit` Pixeln je Seiteneinheit (begrenzt auf eine
|
||||||
|
/// handhabbare Texturgröße). `color` ist linear wie die Tinte; die Pixel sind sRGB.
|
||||||
|
pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage {
|
||||||
|
let layout = text.layout();
|
||||||
|
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
|
||||||
|
let scale = px_per_unit
|
||||||
|
.min(MAX_TEXTURE_SIDE / size.x)
|
||||||
|
.min(MAX_TEXTURE_SIDE / size.y)
|
||||||
|
.min((MAX_TEXTURE_PIXELS / (size.x * size.y)).sqrt())
|
||||||
|
.max(1e-3);
|
||||||
|
let width = ((size.x * scale).ceil() as u32).max(1);
|
||||||
|
let height = ((size.y * scale).ceil() as u32).max(1);
|
||||||
|
|
||||||
|
let face = text.font.face();
|
||||||
|
// ab_glyph misst die Skalierung als Höhe Ober- bis Unterlänge
|
||||||
|
let px_scale = PxScale::from(text.size * scale * text.font.height_per_em());
|
||||||
|
let mut coverage = vec![0.0_f32; (width * height) as usize];
|
||||||
|
for (i, line) in layout.lines.iter().enumerate() {
|
||||||
|
let baseline = layout.baseline(i) * scale;
|
||||||
|
for &(id, x) in &line.glyphs {
|
||||||
|
let glyph = id.with_scale_and_position(px_scale, ab_glyph::point(x * scale, baseline));
|
||||||
|
let Some(outlined) = face.outline_glyph(glyph) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let bounds = outlined.px_bounds();
|
||||||
|
outlined.draw(|gx, gy, c| {
|
||||||
|
let px = bounds.min.x as i64 + gx as i64;
|
||||||
|
let py = bounds.min.y as i64 + gy as i64;
|
||||||
|
if px >= 0 && py >= 0 && (px as u32) < width && (py as u32) < height {
|
||||||
|
let cell = &mut coverage[(py as u32 * width + px as u32) as usize];
|
||||||
|
*cell = (*cell + c).min(1.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rgb = [0, 1, 2].map(|i| egui::ecolor::gamma_u8_from_linear_f32(color[i]));
|
||||||
|
let alpha = color[3].clamp(0.0, 1.0);
|
||||||
|
let mut pixels = Vec::with_capacity(coverage.len() * 4);
|
||||||
|
for c in coverage {
|
||||||
|
pixels.extend_from_slice(&rgb);
|
||||||
|
// Wie egui: Deckung anheben, sonst wirkt die dünne Schrift verkleinert blass
|
||||||
|
pixels.push((c.powf(COVERAGE_GAMMA) * alpha * 255.0).round() as u8);
|
||||||
|
}
|
||||||
|
RgbaImage {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::PAGE_WIDTH;
|
||||||
|
|
||||||
|
fn sample(content: &str, wrap_width: f32) -> TextBox {
|
||||||
|
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fonts_load() {
|
||||||
|
for font in [TextFont::Sans, TextFont::Mono] {
|
||||||
|
assert!(font.face().glyph_id('ä').0 != 0);
|
||||||
|
let factor = font.height_per_em();
|
||||||
|
assert!((1.0..1.4).contains(&factor), "{factor}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_layout_wraps_at_spaces() {
|
||||||
|
let text = sample("eins zwei drei vier fünf sechs", 400.0);
|
||||||
|
let layout = text.layout();
|
||||||
|
assert!(layout.lines.len() > 1);
|
||||||
|
for line in &layout.lines {
|
||||||
|
assert!(line.width <= 400.0 + 1e-3, "{line:?}");
|
||||||
|
assert!(!line.text.starts_with(' ') && !line.text.ends_with(' '));
|
||||||
|
}
|
||||||
|
let words: Vec<String> = layout.lines.iter().map(|l| l.text.clone()).collect();
|
||||||
|
assert_eq!(words.join(" "), "eins zwei drei vier fünf sechs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_layout_breaks_long_words_and_keeps_newlines() {
|
||||||
|
let text = sample("Donaudampfschifffahrtsgesellschaft\n\nende", 300.0);
|
||||||
|
let layout = text.layout();
|
||||||
|
assert!(layout.lines.iter().all(|l| l.width <= 300.0 + 1e-3));
|
||||||
|
let joined: String = layout.lines.iter().map(|l| l.text.as_str()).collect();
|
||||||
|
assert_eq!(joined, "Donaudampfschifffahrtsgesellschaftende");
|
||||||
|
// Leere Zeile bleibt erhalten
|
||||||
|
assert!(layout.lines.iter().any(|l| l.text.is_empty()));
|
||||||
|
assert_eq!(layout.lines.last().unwrap().text, "ende");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_text_is_one_line_high() {
|
||||||
|
let layout = sample("", 500.0).layout();
|
||||||
|
assert!((layout.height() - layout.line_height).abs() < 1e-3);
|
||||||
|
assert!(layout.line_height > 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tabs_become_spaces_and_indent_is_kept() {
|
||||||
|
let text = TextBox::new("\tx".into(), 50.0, TextFont::Mono, 2000.0);
|
||||||
|
let line = &text.layout().lines[0];
|
||||||
|
assert_eq!(line.text, " x");
|
||||||
|
// Festbreitenschrift: x steht vier Zeichenbreiten weiter
|
||||||
|
let advance = line.glyphs[1].1;
|
||||||
|
assert!((line.glyphs[4].1 - 4.0 * advance).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frame_round_trip() {
|
||||||
|
let size = glam::Vec2::new(400.0, 120.0);
|
||||||
|
let axes = [glam::Vec2::new(0.0, 2.0), glam::Vec2::new(-2.0, 0.0)];
|
||||||
|
let corners = frame_corners(glam::Vec2::new(100.0, 50.0), axes, size);
|
||||||
|
let (origin, back) = frame_axes(&corners, size);
|
||||||
|
assert_eq!(origin, glam::Vec2::new(100.0, 50.0));
|
||||||
|
assert!((back[0] - axes[0]).length() < 1e-4 && (back[1] - axes[1]).length() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rasterize_draws_ink_in_color() {
|
||||||
|
let text = sample("Hallo", 400.0);
|
||||||
|
let image = rasterize(&text, [0.85, 0.2, 0.2, 1.0], 1.0);
|
||||||
|
assert_eq!(image.width, 400);
|
||||||
|
assert_eq!(image.height as f32, text.layout().height().ceil());
|
||||||
|
let opaque = image.pixels.chunks(4).filter(|p| p[3] > 200).count();
|
||||||
|
assert!(opaque > 100, "{opaque}");
|
||||||
|
// Farbe ist sRGB-kodiert
|
||||||
|
assert!(image.pixels.chunks(4).all(|p| p[0] > 230 && p[1] < 130));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_editing_new_text_box() {
|
||||||
|
let edit = TextEditing::new_at(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(500.0, 800.0),
|
||||||
|
PAGE_WIDTH,
|
||||||
|
50.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0; 4],
|
||||||
|
);
|
||||||
|
assert!((edit.text.wrap_width - (PAGE_WIDTH - RIGHT_MARGIN - 500.0)).abs() < 1e-3);
|
||||||
|
assert!(edit.origin.y < 800.0);
|
||||||
|
assert!(!edit.is_changed());
|
||||||
|
assert!(edit.to_stroke(9).is_none(), "leeres Feld wird verworfen");
|
||||||
|
|
||||||
|
// Ganz rechts: Feld wird nach links versetzt, damit es mindestens 3 cm breit ist
|
||||||
|
let edge = TextEditing::new_at(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(2050.0, 100.0),
|
||||||
|
PAGE_WIDTH,
|
||||||
|
50.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0; 4],
|
||||||
|
);
|
||||||
|
assert!((edge.text.wrap_width - MIN_WRAP_WIDTH).abs() < 1e-3);
|
||||||
|
assert!(edge.origin.x + MIN_WRAP_WIDTH <= PAGE_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_editing_keeps_transform_and_grows_with_text() {
|
||||||
|
let mut edit = TextEditing::new_at(
|
||||||
|
1,
|
||||||
|
glam::Vec2::new(400.0, 400.0),
|
||||||
|
PAGE_WIDTH,
|
||||||
|
50.0,
|
||||||
|
TextFont::Sans,
|
||||||
|
[0.0; 4],
|
||||||
|
);
|
||||||
|
edit.text.content = "eine Zeile".into();
|
||||||
|
let mut stroke = edit.to_stroke(5).unwrap();
|
||||||
|
assert!(stroke.is_text() && stroke.is_object());
|
||||||
|
stroke.rotate(glam::Vec2::new(400.0, 400.0), 0.5);
|
||||||
|
stroke.scale(glam::Vec2::new(400.0, 400.0), glam::Vec2::new(2.0, 2.0));
|
||||||
|
let before = stroke.object_corners().unwrap();
|
||||||
|
|
||||||
|
let mut edit = TextEditing::of_stroke(1, &stroke).unwrap();
|
||||||
|
assert!(!edit.is_changed());
|
||||||
|
edit.text.content = "eine Zeile\nzwei Zeilen".into();
|
||||||
|
assert!(edit.is_changed());
|
||||||
|
let after = edit.to_stroke(5).unwrap().object_corners().unwrap();
|
||||||
|
// Obere Kante bleibt, die Seitenkanten werden in ihrer Richtung doppelt so lang
|
||||||
|
assert!((after[0] - before[0]).length() < 1e-2 && (after[1] - before[1]).length() < 1e-2);
|
||||||
|
let (old_side, new_side) = (before[3] - before[0], after[3] - after[0]);
|
||||||
|
assert!((new_side.length() / old_side.length() - 2.0).abs() < 1e-3);
|
||||||
|
assert!(old_side.normalize().dot(new_side.normalize()) > 0.9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rasterize_limits_texture_size() {
|
||||||
|
let text = sample("x", 2000.0);
|
||||||
|
let image = rasterize(&text, [0.0, 0.0, 0.0, 1.0], 100.0);
|
||||||
|
assert!(image.width as f32 <= MAX_TEXTURE_SIDE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
-59
@@ -7,7 +7,7 @@
|
|||||||
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
|
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
|
||||||
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
|
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
|
||||||
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
|
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
|
||||||
//! - Erweiterbare Schnittstelle für optionale Signaturprüfung (.sig / .minisig)
|
//! - Pflicht-Signaturprüfung der `SHA256SUMS.txt` via Minisign (eingebauter öffentlicher Schlüssel)
|
||||||
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
|
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
|
||||||
//! - Echtes Fortschritts-Streaming (~10 Updates/s)
|
//! - Echtes Fortschritts-Streaming (~10 Updates/s)
|
||||||
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
|
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
|
||||||
@@ -313,22 +313,78 @@ pub fn verify_checksum(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status einer Signaturverifikation (.sig / .minisig).
|
/// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
/// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
|
||||||
pub enum SignatureVerificationStatus {
|
/// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
|
||||||
NotPresent,
|
/// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
|
||||||
Verified { signer: String },
|
pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
|
||||||
Failed(String),
|
"RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
|
||||||
|
|
||||||
|
/// Name des Prüfsummen-Assets eines Releases.
|
||||||
|
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
|
||||||
|
/// Name der Minisign-Signatur zu [`CHECKSUMS_ASSET_NAME`].
|
||||||
|
pub const CHECKSUMS_SIGNATURE_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
|
||||||
|
|
||||||
|
/// Prüft die Minisign-Signatur der Prüfsummendatei. Nur aktuelle (prehashed) Signaturen
|
||||||
|
/// werden akzeptiert; ohne hinterlegten Schlüssel schlägt die Prüfung immer fehl.
|
||||||
|
pub fn verify_checksums_signature(
|
||||||
|
checksums: &[u8],
|
||||||
|
signature_text: &str,
|
||||||
|
public_key_b64: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let key = public_key_b64.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
|
||||||
|
}
|
||||||
|
let public_key = minisign_verify::PublicKey::from_base64(key)
|
||||||
|
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
|
||||||
|
let signature = minisign_verify::Signature::decode(signature_text)
|
||||||
|
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
|
||||||
|
public_key
|
||||||
|
.verify(checksums, &signature, false)
|
||||||
|
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}."))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erweiterbare Schnittstelle für kryptografische Signaturprüfung.
|
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
|
||||||
pub fn verify_signature_if_present(
|
fn fetch_release_text(
|
||||||
_asset_file: &Path,
|
agent: &ureq::Agent,
|
||||||
_sig_bytes: Option<&[u8]>,
|
release: &GiteaRelease,
|
||||||
) -> Result<SignatureVerificationStatus, String> {
|
name: &str,
|
||||||
// Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key
|
config: &UpdaterConfig,
|
||||||
// im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen.
|
) -> Result<String, String> {
|
||||||
Ok(SignatureVerificationStatus::NotPresent)
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name == name)
|
||||||
|
.ok_or_else(|| format!("Release enthält keine {name}."))?;
|
||||||
|
http_get_with_auth_guard(
|
||||||
|
agent,
|
||||||
|
&asset.browser_download_url,
|
||||||
|
config.auth_token.as_deref(),
|
||||||
|
5,
|
||||||
|
config.allow_insecure_http,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))?
|
||||||
|
.into_string()
|
||||||
|
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
|
||||||
|
/// Signatur zurück.
|
||||||
|
fn fetch_verified_checksums(
|
||||||
|
agent: &ureq::Agent,
|
||||||
|
release: &GiteaRelease,
|
||||||
|
config: &UpdaterConfig,
|
||||||
|
public_key_b64: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Ohne Schlüssel gar nicht erst Netzwerkanfragen stellen
|
||||||
|
if public_key_b64.trim().is_empty() {
|
||||||
|
return verify_checksums_signature(b"", "", public_key_b64).map(|()| String::new());
|
||||||
|
}
|
||||||
|
let checksums = fetch_release_text(agent, release, CHECKSUMS_ASSET_NAME, config)?;
|
||||||
|
let signature = fetch_release_text(agent, release, CHECKSUMS_SIGNATURE_ASSET_NAME, config)?;
|
||||||
|
verify_checksums_signature(checksums.as_bytes(), &signature, public_key_b64)?;
|
||||||
|
Ok(checksums)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event-Typen für den asynchronen Worker.
|
/// Event-Typen für den asynchronen Worker.
|
||||||
@@ -368,6 +424,17 @@ pub enum UpdaterStatus {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lehnt jede URL ohne HTTPS ab, sofern unsicheres HTTP nicht ausdrücklich erlaubt ist.
|
||||||
|
fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), String> {
|
||||||
|
match url.scheme() {
|
||||||
|
"https" => Ok(()),
|
||||||
|
"http" if allow_insecure_http => Ok(()),
|
||||||
|
scheme => Err(format!(
|
||||||
|
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Erstellt einen ureq Agent mit sauberen Timeouts.
|
/// Erstellt einen ureq Agent mit sauberen Timeouts.
|
||||||
fn create_http_agent() -> ureq::Agent {
|
fn create_http_agent() -> ureq::Agent {
|
||||||
ureq::AgentBuilder::new()
|
ureq::AgentBuilder::new()
|
||||||
@@ -384,12 +451,15 @@ fn http_get_with_auth_guard(
|
|||||||
initial_url_str: &str,
|
initial_url_str: &str,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
max_redirects: usize,
|
max_redirects: usize,
|
||||||
|
allow_insecure_http: bool,
|
||||||
) -> Result<ureq::Response, String> {
|
) -> Result<ureq::Response, String> {
|
||||||
let mut current_url = url::Url::parse(initial_url_str)
|
let mut current_url = url::Url::parse(initial_url_str)
|
||||||
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
|
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
|
||||||
let initial_origin = current_url.origin().ascii_serialization();
|
let initial_origin = current_url.origin().ascii_serialization();
|
||||||
|
|
||||||
for _ in 0..=max_redirects {
|
for _ in 0..=max_redirects {
|
||||||
|
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
|
||||||
|
ensure_secure_scheme(¤t_url, allow_insecure_http)?;
|
||||||
let mut req = agent.get(current_url.as_str());
|
let mut req = agent.get(current_url.as_str());
|
||||||
|
|
||||||
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
|
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
|
||||||
@@ -500,7 +570,13 @@ impl UpdaterController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let agent = create_http_agent();
|
let agent = create_http_agent();
|
||||||
match http_get_with_auth_guard(&agent, &api_url, config.auth_token.as_deref(), 5) {
|
match http_get_with_auth_guard(
|
||||||
|
&agent,
|
||||||
|
&api_url,
|
||||||
|
config.auth_token.as_deref(),
|
||||||
|
5,
|
||||||
|
config.allow_insecure_http,
|
||||||
|
) {
|
||||||
Ok(resp) => match resp.into_json::<GiteaRelease>() {
|
Ok(resp) => match resp.into_json::<GiteaRelease>() {
|
||||||
Ok(release) => {
|
Ok(release) => {
|
||||||
if release.prerelease && !config.include_prereleases {
|
if release.prerelease && !config.include_prereleases {
|
||||||
@@ -563,12 +639,37 @@ impl UpdaterController {
|
|||||||
let target_file = cache_dir.join(&safe_asset_name);
|
let target_file = cache_dir.join(&safe_asset_name);
|
||||||
let agent = create_http_agent();
|
let agent = create_http_agent();
|
||||||
|
|
||||||
// 1. Download des Release-Assets mit Fortschritts-Streaming
|
// 1. Signierte Prüfsummen laden und verifizieren, BEVOR das Paket geladen wird
|
||||||
|
let checksums_content = match active_release.as_ref() {
|
||||||
|
Some(release) => match fetch_verified_checksums(
|
||||||
|
&agent,
|
||||||
|
release,
|
||||||
|
&config,
|
||||||
|
UPDATE_SIGNING_PUBLIC_KEY,
|
||||||
|
) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
||||||
|
"{e} Download abgelehnt."
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let _ = tx.send(UpdaterEvent::DownloadError(
|
||||||
|
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Download des Release-Assets mit Fortschritts-Streaming
|
||||||
let resp = match http_get_with_auth_guard(
|
let resp = match http_get_with_auth_guard(
|
||||||
&agent,
|
&agent,
|
||||||
&asset.browser_download_url,
|
&asset.browser_download_url,
|
||||||
config.auth_token.as_deref(),
|
config.auth_token.as_deref(),
|
||||||
5,
|
5,
|
||||||
|
config.allow_insecure_http,
|
||||||
) {
|
) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -646,53 +747,13 @@ impl UpdaterController {
|
|||||||
|
|
||||||
drop(file);
|
drop(file);
|
||||||
|
|
||||||
// 2. Integritätsprüfung gegen SHA256SUMS.txt
|
// 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
|
||||||
let checksum_asset = active_release
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|r| r.assets.iter().find(|a| a.name == "SHA256SUMS.txt"));
|
|
||||||
|
|
||||||
let checksums_content = match checksum_asset {
|
|
||||||
Some(ca) => {
|
|
||||||
match http_get_with_auth_guard(
|
|
||||||
&agent,
|
|
||||||
&ca.browser_download_url,
|
|
||||||
config.auth_token.as_deref(),
|
|
||||||
5,
|
|
||||||
) {
|
|
||||||
Ok(c_resp) => match c_resp.into_string() {
|
|
||||||
Ok(text) => text,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = std::fs::remove_file(&target_file);
|
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
|
||||||
"Konnte SHA256SUMS.txt nicht dekodieren: {e}. Datei gelöscht."
|
|
||||||
)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
let _ = std::fs::remove_file(&target_file);
|
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(format!(
|
|
||||||
"Konnte SHA256SUMS.txt nicht laden: {e}. Datei aus Sicherheitsgründen gelöscht."
|
|
||||||
)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let _ = std::fs::remove_file(&target_file);
|
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(
|
|
||||||
"Release enthält keine SHA256SUMS.txt-Prüfsummen. Download abgelehnt und gelöscht.".to_string(),
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
|
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
|
||||||
let _ = tx.send(UpdaterEvent::DownloadError(err));
|
let _ = tx.send(UpdaterEvent::DownloadError(err));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
|
// 4. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
|
||||||
if safe_asset_name.ends_with(".AppImage") {
|
if safe_asset_name.ends_with(".AppImage") {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -996,6 +1057,81 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
|||||||
assert!(allowed_insecure.validate_url().is_ok());
|
assert!(allowed_insecure.validate_url().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nur für Tests erzeugtes Minisign-Schlüsselpaar (der geheime Schlüssel wurde verworfen).
|
||||||
|
const TEST_PUBLIC_KEY: &str = "RWQr5S8CVgsjS4UA3uY0xqTDua4uRSSzKWpFfJKLCypjgP3NGt9Tmdbh";
|
||||||
|
const TEST_CHECKSUMS: &str = "abc123 ink-notes_9.9.9_amd64.deb\n";
|
||||||
|
const TEST_SIGNATURE: &str = "untrusted comment: signature from rsign secret key
|
||||||
|
RUQr5S8CVgsjS5BYMUx1ICe1E356aP7wesQXw+oKF6Rd8WsvdF1dmCb1ffonXgJ2GhWE5iAWPXQaT8Y/Y4zLIpyS1PA8JCe8eAM=
|
||||||
|
trusted comment: ink-notes test
|
||||||
|
iw0NOedyAQg3emO36fHjyfn+AbnI7Idyl+NwFTDJX1YceLM1jSfjIfeIc0D49IQXyGTpM/QnkZJkRO/eI8ZKBQ==
|
||||||
|
";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_checksums_signature_verification() {
|
||||||
|
// Gültige Signatur
|
||||||
|
assert!(verify_checksums_signature(
|
||||||
|
TEST_CHECKSUMS.as_bytes(),
|
||||||
|
TEST_SIGNATURE,
|
||||||
|
TEST_PUBLIC_KEY
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
|
||||||
|
// Manipulierte Prüfsummen (z. B. ausgetauschter Hash)
|
||||||
|
let tampered = TEST_CHECKSUMS.replace("abc123", "abc124");
|
||||||
|
assert!(
|
||||||
|
verify_checksums_signature(tampered.as_bytes(), TEST_SIGNATURE, TEST_PUBLIC_KEY)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fremder Schlüssel
|
||||||
|
let other_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
|
||||||
|
assert!(
|
||||||
|
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, other_key)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kein Schlüssel hinterlegt -> strikt ablehnen
|
||||||
|
let err =
|
||||||
|
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, "").unwrap_err();
|
||||||
|
assert!(err.contains("kein Signaturschlüssel"), "{err}");
|
||||||
|
|
||||||
|
// Fehlende / kaputte Signatur
|
||||||
|
assert!(
|
||||||
|
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), "", TEST_PUBLIC_KEY).is_err()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Altes (nicht prehashed) Signaturformat wird nicht akzeptiert
|
||||||
|
let legacy_sig = "untrusted comment: signature from minisign secret key
|
||||||
|
RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=
|
||||||
|
trusted comment: timestamp:1555779966\tfile:test
|
||||||
|
QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA==";
|
||||||
|
assert!(verify_checksums_signature(b"test", legacy_sig, other_key).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_https_enforced_for_every_request() {
|
||||||
|
let https = url::Url::parse("https://gitea.pansi.eu/a.deb").unwrap();
|
||||||
|
let http = url::Url::parse("http://cdn.example.com/a.deb").unwrap();
|
||||||
|
let ftp = url::Url::parse("ftp://example.com/a.deb").unwrap();
|
||||||
|
assert!(ensure_secure_scheme(&https, false).is_ok());
|
||||||
|
assert!(ensure_secure_scheme(&http, false).is_err());
|
||||||
|
assert!(ensure_secure_scheme(&http, true).is_ok());
|
||||||
|
assert!(ensure_secure_scheme(&ftp, true).is_err());
|
||||||
|
|
||||||
|
// Ein http-Download-Link aus der Release-API wird abgewiesen, bevor eine
|
||||||
|
// Verbindung aufgebaut wird
|
||||||
|
let agent = create_http_agent();
|
||||||
|
let err = http_get_with_auth_guard(
|
||||||
|
&agent,
|
||||||
|
"http://127.0.0.1:9/ink-notes.AppImage",
|
||||||
|
Some("geheim"),
|
||||||
|
5,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("HTTPS"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_appimage_atomic_self_update() {
|
fn test_appimage_atomic_self_update() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+233
-9
@@ -3,12 +3,99 @@
|
|||||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||||
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
||||||
|
|
||||||
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
|
use crate::document::{PageViewport, UNITS_PER_MM};
|
||||||
use glam::{Mat4, Vec2, Vec4};
|
use glam::{Mat4, Vec2, Vec4};
|
||||||
|
|
||||||
/// Minimaler und maximaler Zoomfaktor.
|
/// Minimaler und maximaler Zoomfaktor.
|
||||||
pub const MIN_ZOOM: f32 = 0.25;
|
pub const MIN_ZOOM: f32 = 0.25;
|
||||||
pub const MAX_ZOOM: f32 = 5.0;
|
pub const MAX_ZOOM: f32 = 10.0;
|
||||||
|
|
||||||
|
/// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
|
||||||
|
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
|
||||||
|
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
|
||||||
|
pub const MAX_NEIGHBOR_PAGES: usize = 12;
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
|
||||||
|
/// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
|
||||||
|
/// aktuellen Seite.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PageStack {
|
||||||
|
pub origins: Vec<Vec2>,
|
||||||
|
pub sizes: Vec<Vec2>,
|
||||||
|
pub current: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageStack {
|
||||||
|
/// Stapel aus den Seitengrößen; `current` ist die Bezugsseite.
|
||||||
|
pub fn new(sizes: Vec<Vec2>, current: usize) -> Self {
|
||||||
|
let current = current.min(sizes.len().saturating_sub(1));
|
||||||
|
let width = sizes.get(current).map_or(0.0, |s| s.x);
|
||||||
|
let mut origins = vec![Vec2::ZERO; sizes.len()];
|
||||||
|
let x = |i: usize| (width - sizes[i].x) / 2.0;
|
||||||
|
for i in current + 1..sizes.len() {
|
||||||
|
let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
|
||||||
|
origins[i] = Vec2::new(x(i), y);
|
||||||
|
}
|
||||||
|
for i in (0..current).rev() {
|
||||||
|
let y = origins[i + 1].y - PAGE_GAP - sizes[i].y;
|
||||||
|
origins[i] = Vec2::new(x(i), y);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
origins,
|
||||||
|
sizes,
|
||||||
|
current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.sizes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.sizes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seite, auf deren Höhe `y` liegt; `None` im Zwischenraum oder außerhalb.
|
||||||
|
pub fn page_at(&self, y: f32) -> Option<usize> {
|
||||||
|
// Seiten liegen nach Index sortiert untereinander
|
||||||
|
let i = self.origins.partition_point(|o| o.y <= y).checked_sub(1)?;
|
||||||
|
(y <= self.origins[i].y + self.sizes[i].y).then_some(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seiten (außer der aktuellen), die zwischen den Höhen `top` und `bottom` sichtbar sind;
|
||||||
|
/// sehr weit herausgezoomt nur die [`MAX_NEIGHBOR_PAGES`] nächstgelegenen.
|
||||||
|
pub fn visible_neighbors(&self, top: f32, bottom: f32) -> Vec<usize> {
|
||||||
|
if !(top.is_finite() && bottom.is_finite()) || bottom < top {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let first = self
|
||||||
|
.origins
|
||||||
|
.iter()
|
||||||
|
.zip(&self.sizes)
|
||||||
|
.position(|(o, s)| o.y + s.y >= top);
|
||||||
|
let Some(first) = first else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut pages: Vec<usize> = (first..self.len())
|
||||||
|
.take_while(|&i| self.origins[i].y <= bottom)
|
||||||
|
.filter(|&i| i != self.current)
|
||||||
|
.collect();
|
||||||
|
if pages.len() > MAX_NEIGHBOR_PAGES {
|
||||||
|
pages.sort_by_key(|&i| i.abs_diff(self.current));
|
||||||
|
pages.truncate(MAX_NEIGHBOR_PAGES);
|
||||||
|
pages.sort_unstable();
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kleinstes Rechteck um alle Seiten (min, max).
|
||||||
|
pub fn bounds(&self) -> (Vec2, Vec2) {
|
||||||
|
self.origins.iter().zip(&self.sizes).fold(
|
||||||
|
(Vec2::splat(f32::INFINITY), Vec2::splat(f32::NEG_INFINITY)),
|
||||||
|
|(min, max), (&o, &s)| (min.min(o), max.max(o + s)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
|
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -39,6 +126,39 @@ impl ViewportState {
|
|||||||
self.pan_offset = [0.0, 0.0];
|
self.pan_offset = [0.0, 0.0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der bisherigen
|
||||||
|
/// Bezugsseite) wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Waagerecht
|
||||||
|
/// zentriert [`PageViewport`] die neue Seite bereits (ungezoomt); der Pan gleicht nur den
|
||||||
|
/// Zoomanteil aus.
|
||||||
|
pub fn shift_reference_page(&mut self, origin: Vec2) {
|
||||||
|
self.pan_offset[0] += origin.x * (1.0 - 1.0 / self.zoom.max(1e-6));
|
||||||
|
self.pan_offset[1] += origin.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center`
|
||||||
|
/// über dem Notizbuch liegt (zwischen Oberkante der ersten und Unterkante der letzten
|
||||||
|
/// Seite, waagerecht innerhalb der Seitenbreite). `true`, wenn korrigiert wurde.
|
||||||
|
pub fn clamp_continuous_center(
|
||||||
|
&mut self,
|
||||||
|
center: [f32; 2],
|
||||||
|
stack: &PageStack,
|
||||||
|
page_viewport: &PageViewport,
|
||||||
|
) -> bool {
|
||||||
|
if stack.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
|
||||||
|
let (min, max) = stack.bounds();
|
||||||
|
let clamped = c.clamp(min, max.max(min));
|
||||||
|
if (clamped - c).length() < 1e-3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Bildschirm = Viewport + (Seite + Pan) · Maßstab: der Pan gleicht die Differenz aus
|
||||||
|
self.pan_offset[0] += c.x - clamped.x;
|
||||||
|
self.pan_offset[1] += c.y - clamped.y;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
|
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
|
||||||
pub fn pan_by_screen_delta(
|
pub fn pan_by_screen_delta(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -46,7 +166,7 @@ impl ViewportState {
|
|||||||
delta_y: f32,
|
delta_y: f32,
|
||||||
page_viewport: &PageViewport,
|
page_viewport: &PageViewport,
|
||||||
) {
|
) {
|
||||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
let base_scale = page_viewport.scale;
|
||||||
if base_scale * self.zoom > 1e-6 {
|
if base_scale * self.zoom > 1e-6 {
|
||||||
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
|
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
|
||||||
self.pan_offset[1] += delta_y / (base_scale * self.zoom);
|
self.pan_offset[1] += delta_y / (base_scale * self.zoom);
|
||||||
@@ -68,7 +188,7 @@ impl ViewportState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
let base_scale = page_viewport.scale;
|
||||||
|
|
||||||
// Bildschirmposition des Seitenursprungs (0, 0)
|
// Bildschirmposition des Seitenursprungs (0, 0)
|
||||||
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom;
|
let old_offset_x = page_viewport.page_x + self.pan_offset[0] * base_scale * old_zoom;
|
||||||
@@ -90,7 +210,7 @@ impl ViewportState {
|
|||||||
screen_y: f32,
|
screen_y: f32,
|
||||||
page_viewport: &PageViewport,
|
page_viewport: &PageViewport,
|
||||||
) -> Vec2 {
|
) -> Vec2 {
|
||||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
let base_scale = page_viewport.scale;
|
||||||
let effective_scale = base_scale * self.zoom;
|
let effective_scale = base_scale * self.zoom;
|
||||||
if effective_scale < 1e-6 {
|
if effective_scale < 1e-6 {
|
||||||
return Vec2::ZERO;
|
return Vec2::ZERO;
|
||||||
@@ -112,7 +232,8 @@ impl ViewportState {
|
|||||||
page_viewport: &PageViewport,
|
page_viewport: &PageViewport,
|
||||||
) -> Option<Vec2> {
|
) -> Option<Vec2> {
|
||||||
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
||||||
if (0.0..=PAGE_WIDTH).contains(&pt.x) && (0.0..=PAGE_HEIGHT).contains(&pt.y) {
|
let size = page_viewport.page_size;
|
||||||
|
if (0.0..=size.x).contains(&pt.x) && (0.0..=size.y).contains(&pt.y) {
|
||||||
Some(pt)
|
Some(pt)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -127,7 +248,7 @@ impl ViewportState {
|
|||||||
page_viewport: &PageViewport,
|
page_viewport: &PageViewport,
|
||||||
) -> Vec2 {
|
) -> Vec2 {
|
||||||
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
||||||
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
|
pt.clamp(Vec2::ZERO, page_viewport.page_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
|
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
|
||||||
@@ -142,7 +263,7 @@ impl ViewportState {
|
|||||||
page_y: f32,
|
page_y: f32,
|
||||||
page_viewport: &PageViewport,
|
page_viewport: &PageViewport,
|
||||||
) -> [f32; 2] {
|
) -> [f32; 2] {
|
||||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
let base_scale = page_viewport.scale;
|
||||||
let effective_scale = base_scale * self.zoom;
|
let effective_scale = base_scale * self.zoom;
|
||||||
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale;
|
let screen_x = page_viewport.page_x + (page_x + self.pan_offset[0]) * effective_scale;
|
||||||
let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale;
|
let screen_y = page_viewport.page_y + (page_y + self.pan_offset[1]) * effective_scale;
|
||||||
@@ -152,7 +273,7 @@ impl ViewportState {
|
|||||||
/// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader,
|
/// Berechnet die Shader-Transformationsparameter (Scale, Offset) für den Vertex-Shader,
|
||||||
/// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden.
|
/// um DIN-A4-Seitenkoordinaten direkt in den GPU-NDC-Bereich [-1.0, 1.0] abzubilden.
|
||||||
pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) {
|
pub fn ndc_transform(&self, page_viewport: &PageViewport) -> ([f32; 2], [f32; 2]) {
|
||||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
let base_scale = page_viewport.scale;
|
||||||
let effective_scale = base_scale * self.zoom;
|
let effective_scale = base_scale * self.zoom;
|
||||||
|
|
||||||
// ndc_x = (screen_x / window_width) * 2.0 - 1.0
|
// ndc_x = (screen_x / window_width) * 2.0 - 1.0
|
||||||
@@ -190,6 +311,109 @@ impl ViewportState {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const A4: Vec2 = crate::document::A4_SIZE;
|
||||||
|
const PITCH: f32 = A4.y + PAGE_GAP;
|
||||||
|
|
||||||
|
fn a4_stack(count: usize, current: usize) -> PageStack {
|
||||||
|
PageStack::new(vec![A4; count], current)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_continuous_layout() {
|
||||||
|
let stack = a4_stack(5, 1);
|
||||||
|
assert_eq!(stack.page_at(100.0), Some(1));
|
||||||
|
assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
|
||||||
|
assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
|
||||||
|
assert_eq!(stack.page_at(-5.0), None);
|
||||||
|
assert_eq!(stack.page_at(-PAGE_GAP - 5.0), Some(0));
|
||||||
|
assert_eq!(stack.page_at(-PITCH - 5.0), None, "vor der ersten Seite");
|
||||||
|
|
||||||
|
// Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
|
||||||
|
assert_eq!(
|
||||||
|
stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0),
|
||||||
|
vec![2]
|
||||||
|
);
|
||||||
|
// Ganz oben im Notizbuch: keine Seite vor der ersten
|
||||||
|
assert!(a4_stack(5, 0)
|
||||||
|
.visible_neighbors(-PITCH * 3.0, 500.0)
|
||||||
|
.is_empty());
|
||||||
|
// Hinter der letzten Seite: keine weiteren
|
||||||
|
assert_eq!(
|
||||||
|
a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
|
||||||
|
vec![2, 4]
|
||||||
|
);
|
||||||
|
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
|
||||||
|
let many = a4_stack(200, 50).visible_neighbors(-PITCH * 50.0, PITCH * 50.0);
|
||||||
|
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
|
||||||
|
assert!(many.iter().all(|i| i.abs_diff(50) <= 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stack_of_different_page_sizes() {
|
||||||
|
let slide = Vec2::new(2970.0, 1671.0);
|
||||||
|
let stack = PageStack::new(vec![slide, A4, slide], 1);
|
||||||
|
// Folien waagerecht zur A4-Seite zentriert, mit Abstand darüber und darunter
|
||||||
|
assert_eq!(stack.origins[0], Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
||||||
|
assert_eq!(stack.origins[2], Vec2::new(-435.0, A4.y + PAGE_GAP));
|
||||||
|
assert_eq!(stack.page_at(A4.y + PAGE_GAP + 10.0), Some(2));
|
||||||
|
let (min, max) = stack.bounds();
|
||||||
|
assert_eq!(min, Vec2::new(-435.0, -PAGE_GAP - slide.y));
|
||||||
|
assert_eq!(max, Vec2::new(2535.0, A4.y + PAGE_GAP + slide.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shift_reference_page_keeps_view() {
|
||||||
|
let mut vp = ViewportState::new();
|
||||||
|
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
|
||||||
|
vp.pan_by_screen_delta(0.0, -900.0, &page_vp);
|
||||||
|
// Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite
|
||||||
|
let before = vp.page_to_screen(300.0, PITCH + 200.0, &page_vp);
|
||||||
|
vp.shift_reference_page(a4_stack(2, 0).origins[1]);
|
||||||
|
let after = vp.page_to_screen(300.0, 200.0, &page_vp);
|
||||||
|
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shift_to_page_of_other_size_keeps_view_when_zoomed() {
|
||||||
|
let slide = Vec2::new(2970.0, 1671.0);
|
||||||
|
let fit = Vec2::new(2970.0, 2970.0);
|
||||||
|
let view_a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4);
|
||||||
|
let view_slide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide);
|
||||||
|
let stack = PageStack::new(vec![A4, slide], 0);
|
||||||
|
let mut vp = ViewportState::new();
|
||||||
|
vp.zoom_at_cursor([500.0, 300.0], 2.5, &view_a4);
|
||||||
|
vp.pan_by_screen_delta(-40.0, -700.0, &view_a4);
|
||||||
|
let point = Vec2::new(900.0, 300.0);
|
||||||
|
let before = vp.page_to_screen_vec2(stack.origins[1] + point, &view_a4);
|
||||||
|
vp.shift_reference_page(stack.origins[1]);
|
||||||
|
let after = vp.page_to_screen_vec2(point, &view_slide);
|
||||||
|
assert!((before[0] - after[0]).abs() < 0.01 && (before[1] - after[1]).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_continuous_scroll_stays_on_the_notebook() {
|
||||||
|
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
|
||||||
|
let center = [640.0, 425.0];
|
||||||
|
let stack = a4_stack(3, 1);
|
||||||
|
let mut vp = ViewportState::new();
|
||||||
|
// Mitte der Seite: nichts zu korrigieren
|
||||||
|
assert!(!vp.clamp_continuous_center(center, &stack, &page_vp));
|
||||||
|
// Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3)
|
||||||
|
vp.pan_by_screen_delta(0.0, 5000.0, &page_vp);
|
||||||
|
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
|
||||||
|
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
|
||||||
|
assert!(
|
||||||
|
(c.y + PITCH).abs() < 0.1,
|
||||||
|
"Oberkante der ersten Seite: {c:?}"
|
||||||
|
);
|
||||||
|
// Weit nach unten: Unterkante der letzten Seite
|
||||||
|
vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp);
|
||||||
|
assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
|
||||||
|
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
|
||||||
|
assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}");
|
||||||
|
assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_viewport_zoom_limits() {
|
fn test_viewport_zoom_limits() {
|
||||||
let mut vp = ViewportState::new();
|
let mut vp = ViewportState::new();
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"format_version": 99,
|
||||||
|
"id": "future_notebook",
|
||||||
|
"title": "Zukunfts-Notizbuch",
|
||||||
|
"created_at": 1800000000,
|
||||||
|
"modified_at": 1800001000,
|
||||||
|
"current_page_index": 0,
|
||||||
|
"pages": [
|
||||||
|
{ "id": 1, "background": "Grid5mm" }
|
||||||
|
],
|
||||||
|
"page_ids": [1],
|
||||||
|
"next_stroke_id": 10,
|
||||||
|
"next_page_id": 2
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"format_version": 99,
|
||||||
|
"id": 1,
|
||||||
|
"background": "Blank",
|
||||||
|
"strokes": []
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "legacy_notebook",
|
||||||
|
"title": "Legacy Notizbuch v0",
|
||||||
|
"created_at": 1700000000,
|
||||||
|
"modified_at": 1700001000,
|
||||||
|
"current_page_index": 0,
|
||||||
|
"page_ids": [1],
|
||||||
|
"next_stroke_id": 5,
|
||||||
|
"next_page_id": 2
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"background": "DotGrid",
|
||||||
|
"strokes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"color": [0.1, 0.2, 0.3, 1.0],
|
||||||
|
"samples": [
|
||||||
|
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
|
||||||
|
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"id": 1,
|
||||||
|
"background": "Lined",
|
||||||
|
"strokes": [],
|
||||||
|
"unknown_experimental_feature": {
|
||||||
|
"nested_data": [1, 2, 3],
|
||||||
|
"author": "Antigravity"
|
||||||
|
},
|
||||||
|
"random_future_flag": true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user