Compare commits

..
Author SHA1 Message Date
haraldandClaude Opus 5.5 614dd140a1 docs: README und Dokumentation auf den aktuellen Stand
README, ARCHITECTURE.md und FILE_FORMAT.md beschreiben jetzt alle
Funktionen bis v0.11 (Text, Bilder, PDF-Import, Seitenformate, Lineal,
durchgehendes Scrollen, Musterfarben, Dateiformat-Version 8). Werbliche
Formulierungen durch überprüfbare Angaben ersetzt, auch in Paket-
beschreibung, Desktop-Datei, AppStream-Metadaten und Release-Notizen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:42 +02:00
haraldandClaude Opus 5.5 0a6b5e78ef feat: Farbe von Karos, Linien und Punkten wählbar
Im ☰-Menü unter „Karos, Linien und Punkte“: hell (bisher), mittel,
kräftig, blau oder grün – passend zur gewählten Papierfarbe. Die Wahl
wird gespeichert und gilt auch für PDF-, PNG- und SVG-Export (Issue #6).
Punkte des Punktrasters sind am Bildschirm jetzt 0,4 mm groß wie im
Export (bisher 0,25 mm, beim Herauszoomen kaum sichtbar).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:21:42 +02:00
haraldandClaude Opus 5.5 901c91d10c chore(release): bump version to 0.11.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 6m13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:50:54 +02:00
haraldandClaude Opus 5.5 6caf3f7476 feat: Seitenformat folgt dem PDF (Querformat für Folien)
Seiten haben jetzt eine eigene Größe. Beim PDF-Import bleiben Hochformat-
Seiten DIN A4; Querformat-Seiten wie PowerPoint-Folien bekommen eine
Querformat-Seite (297 mm breit, Höhe nach Seitenverhältnis) ohne leeren
Streifen darunter (Issue #4). Eingefügte und angehängte Seiten übernehmen
das Format der aktuellen Seite.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:27 +02:00
24 changed files with 1965 additions and 1203 deletions
Generated
+1 -1
View File
@@ -1393,7 +1393,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.10.0" version = "0.11.0"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"arboard", "arboard",
+2 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.10.0" version = "0.11.0"
edition = "2021" edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux" description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
+170 -238
View File
@@ -1,285 +1,217 @@
# 🖋️ ink-notes # ink-notes
Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung für Linux, entwickelt in **Rust** mit **wgpu** und **winit**. Eine Notizbuch-App für Linux zum Schreiben mit Stift, Finger oder Maus – geschrieben in **Rust** mit **wgpu**, **winit** und **egui**.
`ink-notes` kombiniert minimale Eingabelatenz beim Zeichnen mit Stift, Touchpad oder Maus mit einem flüssigen stufenlosen Viewport, automatischer Geometrie- und Formerkennung, Objekttransformationen, Multi-Dokumenten-Verwaltung und Vektor-PDF-Export. ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
--- ---
## ✨ Features im Überblick ## Funktionen
### ⚡ Latenzoptimierte Inking-Pipeline ### Schreiben und Zeichnen
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung. - **Stift** mit Druckstärke, vier Strichstärken (0,3–1,2 mm) und fünf Farben; **Textmarker** (halbtransparent, 4–8 mm) in vier Farben.
- **Zwei-Stufen-Modell:** - **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
1. *Live-Phase:* Eingehende Stylus-Samples werden in Echtzeit durch einen 1€-Filter (One-Euro-Filter) entprellt und inkrementell als GPU-Meshes dargestellt. - **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
2. *Commit-Phase (Post-Stroke Finalisierung):* Akausale Ecken-Erkennung (Corner Preservation bei Knicken > 65°), gewichtetes 3-Punkt-Druckfenster, Ramer-Douglas-Peucker (RDP) Dezimierung und saubere runde Endkappen. - **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker. Stiftrückseite und Seitentaste des Stifts radieren ebenfalls.
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen. - **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
- **Werkzeuge:**
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
- **Radierer (Eraser):** Segment- und abstandsbasierte Löschung einzelner Striche auf dem DIN-A4-Canvas.
- **Text (`T`):** Ins Blatt tippen und mit der Tastatur schreiben – für Überschriften, Code-Schnipsel oder kopierten Text. Normale Schrift oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8 bis 36 pt, Farben der Stiftpalette; Zeilen werden am rechten Rand des Textfelds umbrochen. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren; im PDF-Export ist der Text echt (durchsuchbar und kopierbar). Aus anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
### 🔍 Stufenloser Zoom & Pan (Kamera) ### Formerkennung
- **Viewport-Transformation:** 2D-Projektionsmatrix im WGSL-Vertex-Shader. - **Formen-Werkzeug** (`S`/`F`) oder **Halten** am Strichende (über 400 ms stillhalten): Die Skizze wird durch eine saubere Form ersetzt.
- **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). - Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme und Oktaeder (Skizze als 3D-Körper).
- **Cursor-zentrierter Zoom:** Stufenloses Zoomen von **25 % bis 500 %** zentriert auf die Cursor-Position via `Strg + Mausrad` (ohne durchgehende Ansicht: Mausrad), Trackpad-Pinch oder Tastenkürzel (`Strg +` / `Strg -` / `Strg 0`). - Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
- **Pan:** Verschieben des Canvas per mittlerer Maustaste, `Leertaste + Drag` oder Trackpad-Pan.
- **Inverse Koordinatentransformation:** Präzise Umrechnung von Bildschirm- in DIN-A4-Dokumentkoordinaten (`screen_to_page`).
### 📐 Geometrie- & Objekterkennung (Shape Recognition) ### Auswählen und Bearbeiten
- **Erkannte Primitiven:** - **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92. - Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP). - `Strg + C` legt die Auswahl zusätzlich als Bild in die Zwischenablage, z. B. für Chat oder Mail.
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit. - **Rückgängig/Wiederholen** (`Strg + Z/Y`) für alle Änderungen, je Seite.
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit Richtungspfeilen.
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
- **Interaktionsmodelle:**
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
- **Formen-Werkzeug (Shape Mode):** Direkte Erkennung und Ersetzung beim Loslassen.
### 🖐️ Objektauswahl & Transformation (Lasso / Select) ### Text, Bilder und PDFs
- **Lasso & Klick-Auswahl:** Schnelles Markieren einzelner Striche oder Gruppen per Rahmen (`V`). - **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
- **Echtzeit-Verschieben:** Freies Verschieben ausgewählter Objekte über die Seite. - **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
- **Stufenlose Skalierung:** Größenänderung über interaktive Eck-Griffe. - **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
- **Drehung:** Stufenlose Rotation um das Objektzentrum über den oberen Dreh-Griff oder exakte 90°-Schritte mit Taste `R`.
- **Multicolor-Umfärbung:** Ausgewählte Objekte direkt über die Farbpalette umfärben.
- **Clipboard & Editieren:** Kopieren (`Strg + C`), Einfügen (`Strg + V`) und Löschen (`Entf` / `Backspace`).
- **Undo / Redo:** Vollständige Transaktionshistorie (`Strg + Z` / `Strg + Y`) für alle Aktionen inklusive Transformationen.
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung ### Seiten und Ansicht
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert. - **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
- **Dokumenten-Manager:** Toolbar-Modal zum Anlegen, Wechseln, Umbenennen und Löschen von Notizbüchern. - **Papierfarbe** der Anzeige: weiß, creme, dunkelgrau, schwarz; auf dunklem Papier werden dunkle Stiftfarben auf Wunsch aufgehellt. Gespeichert und exportiert wird immer auf weißem Papier.
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab. - **Durchgehendes Scrollen:** Seiten hängen untereinander; wer im unteren Fünftel der letzten Seite schreibt, bekommt automatisch eine neue Seite. Abschaltbar (dann Seite für Seite blättern).
- **Zoom** von 25 % bis 1000 % (`Strg + Mausrad`, zwei Finger, `Strg + +/-/0`), Verschieben mit mittlerer Maustaste, `Leertaste + Ziehen` oder einem Finger.
- **Seiten** anhängen oder direkt nach der aktuellen Seite einfügen, **Seitentitel** vergeben; die Seitenübersicht zeigt Vorschaubilder oder ein Inhaltsverzeichnis. Seitentitel werden im PDF zu Lesezeichen.
- **Werkzeugleiste** links oder rechts andocken (für Links- und Rechtshänder).
### 📄 Seiten & Hintergründe ### Notizbücher und Speichern
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm). - Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen).
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm). - **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen.
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü. - Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
### 💾 Autosave & Persistenz ### Export
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren. - **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen. - **Einzelne Seite** als PNG (mit oder ohne Hintergrund) oder SVG über das ☰-Menü.
- **Dediziertes Speichern (`Strg + S`):** Sofortiger, synchroner Disk-Flush mit visueller Toast-Rückmeldung.
- **Sicherer Shutdown:** `Strg + Q` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
### 🖨️ Vektor-PDF-Export ### Eingabegeräte
- Exportiert das Dokument via `printpdf` nach `~/Documents/<Titel>.pdf` (mit automatischer Kollisionsvermeidung `_1`, `_2`). - Stifte über das Tablet-Protokoll von Wayland bzw. X11 (Druck, Stiftrückseite, Seitentaste).
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben. Kürzel: `Strg + E`. - Solange der Stift aufliegt oder in der Nähe ist, werden Berührungen mit der Hand ignoriert.
- Finger: standardmäßig scrollen und zoomen; Zeichnen mit dem Finger lässt sich im ☰-Menü einschalten.
### Updates
- Die App prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch. Installiert wird nur, wenn die Minisign-Signatur und die SHA-256-Prüfsumme stimmen (Details unten).
--- ---
## 📦 Release, Packaging & Updater ## Installation
- **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). Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb` und `.AppImage` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
### 1. Release-Pakete bauen
```bash ```bash
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/ # Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
./scripts/build-release.sh sudo apt install ./ink-notes_<version>_amd64.deb
# oder AppImage
chmod +x ink-notes_<version>_x86_64.AppImage
./ink-notes_<version>_x86_64.AppImage
``` ```
Ergebnis in `target/`: Hinweise:
- `ink-notes_0.1.0_amd64.deb` - Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
- `ink-notes_0.1.0_x86_64.AppImage` - Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
- `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten) ---
## Tastenkürzel
| Aktion | Taste / Geste |
| :--- | :--- |
| Zeichnen | Stiftspitze, linke Maustaste (Finger, wenn eingeschaltet) |
| Radieren | `E` umschalten, rechte Maustaste, Stiftrückseite oder Seitentaste |
| Stift in Schwarz / Blau / Rot | `B` / `U` / `R` |
| Formen-Werkzeug | `S` oder `F` |
| Auswahlwerkzeug | `V` |
| Text-Werkzeug | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
| Lineal ein/aus | `L` |
| Hintergrund wechseln | `G` |
| Auswahl um 90° drehen | `R` (bei Auswahl) |
| Kopieren / Einfügen | `Strg + C` / `Strg + V` |
| Auswahl löschen | `Entf` oder `Rücktaste` |
| Rückgängig / Wiederholen | `Strg + Z` / `Strg + Y` oder `Strg + Umschalt + Z` |
| Bild einfügen | `Strg + I` |
| Scrollen | Mausrad (`Umschalt` = waagerecht), ein Finger |
| Zoomen | `Strg + Mausrad`, zwei Finger, `Strg + +` / `Strg + -` |
| Zoom zurücksetzen | `Strg + 0` |
| Verschieben | mittlere Maustaste oder `Leertaste + Ziehen` |
| Nächste / vorherige Seite | `Bild ab` oder `N` / `Bild auf` |
| Erste / letzte Seite | `Pos 1` / `Ende` |
| Jetzt speichern | `Strg + S` |
| PDF exportieren | `Strg + E` |
| Menüs schließen, Auswahl aufheben | `Esc` |
| Beenden (speichert vorher) | `Strg + Q` oder Fenster schließen |
---
## Entwicklung
### Voraussetzungen
- Rust 1.92 oder neuer (`rustup update stable`)
- Vulkan- oder OpenGL-Treiber (z. B. `mesa-vulkan-drivers`)
- X11-/Wayland-Bibliotheken: `libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`
### Bauen, starten, testen
```bash
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
cargo run --release # oder ./run.sh
cargo test # Unit- und Integrationstests
```
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`.
### Projektstruktur
```text
src/
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
├── lib.rs # Bibliotheks-Exporte (für Tests)
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
├── shader.wgsl # Vertex- und Fragment-Shader
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
├── filter.rs # 1€-Filter gegen Zittern
├── spline.rs # Catmull-Rom-Splines, Ecken-Erhaltung, Dreiecksstreifen
├── geometry.rs # Formerkennung (Douglas-Peucker, Kreis-Fit, …)
├── selection.rs # Auswahl, Griffe, Verschieben/Drehen/Skalieren
├── ruler.rs # Virtuelles Lineal
├── text.rs # Textfelder: Satz, Zeilenumbruch, Rasterung
├── background.rs # Papierfarben, Musterfarben, Karo/Linien/Punkte
├── image_layer.rs # Texturen für Bilder, PDF-Seiten und Text
├── assets.rs # Bilder und PDFs im Notizbuchordner
├── pdf_doc.rs # PDF lesen und rendern (hayro)
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
├── history.rs # Rückgängig/Wiederholen
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export (printpdf)
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
├── file_picker.rs # Eingebaute Dateiauswahl
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json)
├── platform.rs # Wayland/X11-Erkennung
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung)
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge
```
Mehr dazu in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), das Speicherformat steht in [`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md).
---
## Releases bauen und veröffentlichen
```bash
./scripts/build-release.sh # .deb, .AppImage und SHA256SUMS.txt in target/
```
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
### Release-Signatur einrichten (einmalig) ### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird): Updates werden in der App nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Schlüsselpaar ohne Passwort erzeugen (es wird nur als CI-Secret verwendet):
```bash ```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key rsign generate -W -p ink-notes.pub -s ink-notes.key
``` ```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`. 1. Die zweite Zeile von `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen.
2. Den Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` hinterlegen.
3. `ink-notes.key` offline aufbewahren. Geht er verloren, können bestehende Installationen keine Updates mehr prüfen.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen. `rsign generate -W` verschlüsselt den Schlüssel mit leerem Passwort; das `minisign`-Programm (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht lesen, deshalb signieren Skript und CI mit `rsign`. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen passt.
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. ### CI-Runner (act_runner)
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04 Der Workflow läuft auf dem Label `ubuntu-latest`. Im `config.yaml` des Runners muss es zugeordnet sein, z. B.:
```bash
# Natives Debian-Paket installieren
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
# Oder portables AppImage starten
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
./target/ink-notes_0.1.0_x86_64.AppImage
```
#### Hinweise zu AppImage, glibc & FUSE:
- **glibc-Mindestversion:** `ink-notes` benötigt `glibc >= 2.39` (Standard unter Ubuntu 24.04 LTS und Zorin OS 18).
- **Host-Grafikstack:** Das AppImage bündelt die Anwendungslogik, nutzt für maximale Performance und Hardwarebeschleunigung jedoch den Treiber- und Grafikstack des Host-Systems (Vulkan ICD-Treiber, Wayland bzw. X11).
- **FUSE 2 / FUSE 3 Kompatibilität:** Ubuntu 24.04-basierte Distributionen nutzen standardmäßig FUSE 3 (`fuse3`). Sollte das AppImage beim Start einen Fehler bezüglich `libfuse.so.2` ausgeben, kann entweder das Kompatibilitätspaket installiert werden:
```bash
sudo apt install libfuse2t64
```
oder das AppImage direkt ohne FUSE entpackt und ausgeführt werden:
```bash
./ink-notes_0.1.0_x86_64.AppImage --appimage-extract-and-run
```
### 3. Release auf Gitea veröffentlichen
Das Skript arbeitet vollkommen idempotent und aktualisiert bei wiederholter Ausführung Release-Notizen und Assets:
```bash
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
./scripts/publish-gitea-release.sh
```
---
## 🛡️ Updater-Sicherheit
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **Minisign-Signatur (Pflicht):**
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
2. **SHA-256-Integritätsprüfung:**
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
3. **Redirect-Schutz & HTTPS:**
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
5. **Atomarer AppImage-Tausch:**
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
---
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
### Runner-Label konfigurieren
Der Workflow nutzt standardmäßig das Runner-Label:
```yaml
runs-on: ubuntu-latest
```
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
```yaml ```yaml
runner: runner:
labels: labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest" - "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
# oder für native Host-Ausführung:
# - "ubuntu-latest:host"
``` ```
### Diagnose & Fehlerbehebung Bleibt ein Job auf „Waiting to run“ stehen: Ist der Runner online (*Einstellungen → Aktionen → Runner*) und stimmt das Label mit `runs-on` überein? `sudo` verwendet der Workflow nur, wenn der Runner nicht als `root` läuft.
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
--- ---
## 🚀 Schnellstart (Entwicklung) ## Sicherheit der Updates
### Voraussetzungen 1. **Signatur:** Jedes Release enthält `SHA256SUMS.txt.minisig`. Die App lädt `SHA256SUMS.txt` und die Signatur vor dem Paket und prüft sie gegen den eingebauten öffentlichen Schlüssel (`minisign-verify`). Ohne gültige Signatur wird nicht installiert.
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`) 2. **Prüfsumme:** Der SHA-256-Wert des heruntergeladenen Pakets muss zum Eintrag in der signierten Liste passen, sonst wird die Datei gelöscht.
- **System-Bibliotheken (Linux):** 3. **Nur HTTPS:** Auch Download-Links und Weiterleitungen müssen HTTPS sein. Bei Weiterleitungen auf einen anderen Server wird der `Authorization`-Header nicht mitgeschickt.
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber) 4. **Eigener Download-Ordner:** `~/.cache/ink-notes/updates` (Rechte `0700`); Dateinamen mit Pfadtrennern, `..` oder Steuerzeichen werden abgelehnt.
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`) 5. **AppImage-Austausch:** Die neue Datei wird neben der alten angelegt, die alte als `.bak` gesichert und dann per `rename` ersetzt – die laufende Datei wird nie überschrieben.
### Bauen & Ausführen
```bash
# Repository klonen
git clone https://gitea.pansi.eu/harald/ink-notes.git
cd ink-notes
# Kompilieren und starten via Starter-Skript
./run.sh
```
Alternativ direkt mit Cargo:
```bash
cargo run --release
```
--- ---
## ⌨️ Tastatur- & Maus-Shortcuts ## Lizenz
| Aktion | Tastenkürzel / Mausgeste | [MIT](LICENSE), Copyright © 2026 Harald Pansi.
| :--- | :--- |
| **Zeichnen** | Linke Maustaste / Stiftspitze |
| **Canvas verschieben (Pan)** | Mittlere Maustaste **oder** `Leertaste + Drag` |
| **Scrollen (durchgehende Ansicht)** | Mausrad (`Umschalt` = waagerecht) / ein Finger |
| **Zoom zentriert auf Cursor** | `Strg + Mausrad` / Touchpad-Pinch (Einzelseiten-Ansicht: Mausrad) |
| **Zoom zurücksetzen (100 %)** | `Strg + 0` |
| **Zoom In / Out** | `Strg + +` / `Strg + -` |
| **Auswahlwerkzeug (Lasso)** | `V` |
| **Text-Werkzeug** | `T`, dann ins Blatt klicken; `Esc` beendet die Eingabe |
| **Auswahl um 90° drehen** | `R` |
| **Kopieren & Einfügen** | `Strg + C` / `Strg + V` |
| **Auswahl löschen** | `Entf` oder `Backspace` |
| **Undo / Redo** | `Strg + Z` / `Strg + Y` (oder `Strg + Shift + Z`) |
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
| **Vorherige Seite** | `Bild auf (PageUp)` |
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
| **Jetzt speichern (Disk Flush)** | `Strg + S` |
| **Vektor-PDF-Export** | `Strg + E` |
| **Dialoge / Menüs schließen** | `Esc` |
| **Beenden (mit Disk-Flush)** | `Strg + Q` (oder Fenster schließen) |
---
## 🏗️ Projekt- und Modul-Architektur
```text
src/
├── main.rs # Event-Loop (winit), Input-Routing, App-Orchestrierung
├── lib.rs # Bibliotheks-Exporte für Tests und Komponenten
├── canvas.rs # wgpu Render-Pipeline, GPU-Buffer, Zeichenzustand
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
├── spline.rs # Catmull-Rom, Corner Preservation & Dreiecksstreifen
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
├── document.rs # Notizbuch-Datenmodell, Seiten, DIN-A4-Koordinatensystem
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
├── updater.rs # In-App Gitea Updater (SemVer, HTTPS, SHA256-Integrität, Self-Update)
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
```
Ausführliche Details zur Architektur und mathematischen Modellen findest du unter [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
---
## 🧪 Tests ausführen
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
```bash
cargo test
```
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
---
## 📄 Lizenz
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
+154 -117
View File
@@ -1,153 +1,190 @@
# Architektur & Technische Dokumentation von ink-notes # Architektur von ink-notes
Dieses Dokument beschreibt die interne Architektur, mathematische Modelle und Rendering-Pipelines von `ink-notes`. Dieses Dokument beschreibt, wie ink-notes intern aufgebaut ist: Eingabe und Strichverarbeitung, Rendering, Seiten und Ansicht, Formerkennung, Speichern und Export. Das Speicherformat steht in [`FILE_FORMAT.md`](FILE_FORMAT.md).
--- ---
## 1. Die Inking-Pipeline ## 1. Überblick
ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thread. Dazu kommen drei Hintergrund-Threads:
| Thread | Aufgabe |
| :--- | :--- |
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
---
## 2. Vom Stift zum Strich
``` ```
[Hardware Stylus / Touch / Maus] [Stift / Touch / Maus]
│ │ Bildschirmposition, Druck, Zeit
▼ ▼
[winit Event-Loop] [Bildschirm → Seitenkoordinaten] (viewport.rs)
│ (tx, ty, pressure, timestamp) │
▼ ▼
[Viewport Invertierung] (screen_to_page: Umrechnung in DIN-A4-Einheiten) [Druckkurve] (settings.rs, vom Nutzer einstellbar)
│ │
▼ ▼
[1€-Filter] (Jitter-Beseitigung bei langsamen Bewegungen) [1€-Filter] (filter.rs)
│ │
▼ ▼
[Centripetal Catmull-Rom] (C1-stetige Spline-Interpolation, alpha = 0.5) [Catmull-Rom-Spline + Dreiecksstreifen] (spline.rs) → Live-Puffer auf der GPU
│ │ Stift abgehoben
▼ ▼
[Mesh-Tessellierung] (Drucksensitive Dreiecksstreifen via Normalen) [Nachbearbeitung] (spline.rs: finalize_stroke)
│ │
▼ ▼
[wgpu GPU-Buffer] (Vertex- & Index-Buffer im VRAM) [Strich auf der Seite + Undo-Eintrag] (document.rs, history.rs)
``` ```
### 1.1. 1€-Filter (One-Euro-Filter) ### 2.1 1€-Filter
Stift- und Touch-Eingaben unterliegen bei niedrigen Geschwindigkeiten oft Mikrozittern (Jitter). Standard-Tiefpassfilter verursachen jedoch bei schnellen Schwüngen eine störende Verzögerung (Latenz). Langsame Bewegungen zittern, schnelle sollen nicht nachhinken. Der 1€-Filter (`filter.rs`) passt seine Grenzfrequenz an die Geschwindigkeit an:
Der 1€-Filter (`filter.rs`) löst dieses Dilemma mit einer adaptiven Grenzfrequenz $f_c$:
$$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$ $$f_c = f_{c,\min} + \beta \cdot |\dot{x}|$$
- Bei geringer Geschwindigkeit ($|\dot{x}| \approx 0$) dominiert $f_{c,\min}$, wodurch Zittern maximal geglättet wird.
- Bei schnellen Zügen wächst $\beta \cdot |\dot{x}|$ an, wodurch der Filter durchlässig wird und Latenz eliminiert.
### 1.2. Centripetal Catmull-Rom Splines Bei langsamer Bewegung glättet er stark, bei schneller kaum.
Um Ecken zu vermeiden und eine organische Schriftführung zu erzielen, werden Abtastpunkte mit einem zentripetalen Catmull-Rom-Spline (`spline.rs`) interpoliert ($\alpha = 0.5$):
$$t_{i+1} = t_i + ||P_{i+1} - P_i||^\alpha$$
Diese Parametrisierung garantiert:
- Keine Selbstüberschneidungen bei scharfen Wendungen.
- Keine unerwünschten Cusp-Schleifen.
- Perfekte $C^1$-Stetigkeit an den Stützstellen.
### 1.3. Drucksensitive Dreiecksstreifen (Tessellierung) ### 2.2 Zentripetaler Catmull-Rom-Spline
Aus den interpolierten Spline-Punkten mit Radius $r_i = f(\text{pressure}_i)$ wird ein Triangle-Strip erzeugt: Die gefilterten Punkte werden mit einem zentripetalen Catmull-Rom-Spline ($\alpha = 0{,}5$) interpoliert:
1. Richtungsvektor $T_i = P_{i+1} - P_i$.
2. Senkrechter Normalenvektor $N_i = (-T_{i,y}, T_{i,x}) / ||T_i||$.
3. An inneren Stützstellen werden Normalen benachbarter Segmente gemittelt (Winkelhalbierende).
4. Eckpunkte des Dreiecksstreifens:
$$V_{i,\text{left}} = P_i + N_i \cdot r_i, \quad V_{i,\text{right}} = P_i - N_i \cdot r_i$$
### 1.4. Zwei-Stufen-Modell (Live-Glättung vs. akausale Post-Stroke-Finalisierung) $$t_{i+1} = t_i + \lVert P_{i+1} - P_i \rVert^{\alpha}$$
Um minimale Latenz beim Zeichnen mit maximaler Präzision und Ecken-Erhaltung zu verbinden, arbeitet `ink-notes` mit einem strikten Zwei-Stufen-Modell:
1. **Live-Phase (Kausal, Zero-Latency):** Diese Parametrisierung erzeugt bei engen Kurven keine Schlaufen oder Spitzen und ist an den Stützstellen $C^1$-stetig.
- Eingehende Punkte durchlaufen in Echtzeit den 1€-Filter (`filter.rs`).
- `spline.rs` tesselliert inkrementell temporäre Triangle-Strips für die unverzögerte Bildschirmdarstellung im GPU-Live-Buffer. ### 2.3 Dreiecksstreifen
2. **Commit-Phase (Akausal, `finalize_stroke`):** Aus den Spline-Punkten mit Radius $r_i$ (aus Druck und Strichstärke) entsteht ein Dreiecksstreifen:
- Beim Absetzen des Stifts wird der gesamte Strich akausal analysiert:
- **Druckglättung:** Glättung des Druckverlaufs $p$ über ein gewichtetes 3-Punkt-Fenster $[0.25, 0.5, 0.25]$ zur Ausblendung von Digitalisierer-Stufen. $$V_{i,\text{links}} = P_i + N_i \cdot r_i, \qquad V_{i,\text{rechts}} = P_i - N_i \cdot r_i$$
- **RDP-Dezimierung:** Ausdünnung kollinearer Zwischenpunkte mit Ramer-Douglas-Peucker ($\epsilon \approx 0.075\text{ mm} = 0.75\text{ Einheiten}$).
- **Ecken-Erkennung (Corner Preservation):** Bei Richtungsänderungen $> 65^\circ$ wird die $C^1$-Spline-Kontinuität zu $C^0$ aufgebrochen, sodass Ecken (z. B. 90°-L-Knicke oder Zickzack-Linien) gestochen scharf bleiben und nicht rundgeschliffen werden. $N_i$ ist die Normale der Laufrichtung; an inneren Punkten wird über benachbarte Segmente gemittelt.
- **Runde Kappen & Miter-Joins:** Generierung sauberer runder Kappen an Start und Ende des Triangle-Strips.
- Der temporäre Live-Buffer wird vollständig entleert und der bereinigte `Stroke` in den persistenten Puffer der Seite überführt. ### 2.4 Zwei Stufen
1. **Während des Schreibens:** Filter und Spline arbeiten nur mit bisherigen Punkten; der Strich wird sofort in einen eigenen Live-Puffer geschrieben.
2. **Beim Absetzen** wird der ganze Strich noch einmal bearbeitet (`finalize_stroke`):
- Druckverlauf mit dem Fenster $[0{,}25;\ 0{,}5;\ 0{,}25]$ glätten,
- Punkte mit Ramer-Douglas-Peucker ausdünnen ($\varepsilon = 0{,}75$ Einheiten $= 0{,}075$ mm),
- an Knicken über 65° den Spline auftrennen, damit Ecken nicht abgerundet werden,
- runde Endkappen anfügen.
Danach wird der Live-Puffer geleert und der Strich in die Seite übernommen.
### 2.5 Lineal
Beginnt ein Strich nahe einer Linealkante (`ruler.rs`), werden alle seine Punkte auf diese Kante projiziert, bis der Stift abgehoben wird.
--- ---
## 2. Rendering & Kamera-Pipeline (`wgpu`) ## 3. Koordinaten, Seiten und Ansicht
### 2.1. Virtuelles Koordinatensystem ### 3.1 Seitenkoordinaten
Die Anwendung rechnet intern in virtuellen DIN-A4-Koordinaten: Gerechnet wird in Seiteneinheiten: **10 Einheiten = 1 mm**. Eine DIN-A4-Seite ist 2100 × 2970 Einheiten groß. Jede Seite hat ihre eigene Größe (`Page::size`): Standard ist A4 hoch; beim PDF-Import bekommen Querformat-Seiten 2970 Einheiten Breite und eine Höhe nach Seitenverhältnis (`document::pdf_page_size`). Neue Seiten übernehmen die Größe der aktuellen.
- Breite: **2100 Einheiten** (210 mm)
- Höhe: **2970 Einheiten** (297 mm)
- 1 mm entspricht exakt **10 Einheiten**.
### 2.2. Viewport & Projektionsmatrix (`viewport.rs`) ### 3.2 Maßstab und Kamera
Der Vertex-Shader transformiert DIN-A4-Koordinaten über eine Orthogonal-Projektion kombiniert mit Pan und Zoom in Normalized Device Coordinates (NDC, $[-1, 1]$): - `PageViewport` (`document.rs`) passt einen Rahmen, in den alle Seiten des Notizbuchs passen (`Notebook::fit_box`), ins Fenster ein. Daraus ergibt sich ein Maßstab (Bildschirmpixel je Einheit), der für alle Seiten gilt. Die aktuelle Seite liegt waagerecht zentriert und oben im Rahmen. Den Platz der Werkzeugleiste rechnet der Viewport heraus.
- `ViewportState` (`viewport.rs`) ergänzt Zoom (25 % bis 1000 %) und Verschiebung (Pan). Beim Zoomen bleibt der Punkt unter dem Mauszeiger stehen:
```wgsl
struct CameraUniform {
transform: mat4x4<f32>,
};
```
Die Projektionsmatrix $M$ wird berechnet aus:
1. Skalierung des DIN-A4-Papiers auf den Viewport unter Berücksichtigung des Seitenverhältnisses.
2. Anwenden des Zoom-Faktors $z \in [0.25, 5.0]$.
3. Translation durch den Pan-Offset $(p_x, p_y)$.
### 2.3. Cursor-zentrierter Zoom
Beim Zoomen mit dem Mausrad wird der neue Pan-Offset so korrigiert, dass der Punkt unter dem Cursor fixiert bleibt:
$$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$ $$\vec{p}_{\text{neu}} = \vec{c} - (\vec{c} - \vec{p}_{\text{alt}}) \cdot \frac{z_{\text{neu}}}{z_{\text{alt}}}$$
### 2.4. Render-Passes - Im Shader wird jeder Punkt mit `transform_scale` und `transform_offset` (aus `CameraUniforms`) direkt in Gerätekoordinaten umgerechnet.
In jedem Frame werden zwei Hauptpässe durchlaufen:
1. **Pass 1 (Canvas):** ### 3.3 Durchgehende Ansicht
- Hintergrund-Quad und Linien/Gitter (Instanced oder Batch Vertex-Buffer). Alle Koordinaten beziehen sich auf die **aktuelle Seite**. `PageStack` (`viewport.rs`) legt die übrigen Seiten mit 8 mm Abstand darunter bzw. darüber, waagerecht zur aktuellen Seite zentriert. Jede sichtbare Nachbarseite wird mit einer eigenen Kamera gezeichnet, die um die Lage der Seite verschoben ist (höchstens 12 Nachbarseiten).
- Gespeicherte Striche und Geometrie-Meshes.
- Aktiver Live-Strich (Ephemeral Buffer). Nach dem Scrollen wird die Seite in der Fenstermitte zur aktuellen Seite; die Kamera wird dabei so verschoben, dass sich das Bild nicht bewegt. Während eines Strichs, einer Auswahl, beim Ziehen des Lineals oder beim Schreiben von Text wird nicht gewechselt. Wer auf einer sichtbaren Nachbarseite zu zeichnen beginnt, wechselt zuerst auf diese Seite.
- Auswahl-Overlay & Transformationsgriffe.
2. **Pass 2 (egui UI):**
- Floating Glassmorphism-Toolbar.
- Farb- und Werkzeug-Popovers.
- Modal-Dokumentenmanager.
--- ---
## 3. Geometrieerkennung & Shape-Fitting (`geometry.rs`) ## 4. Rendering
Die Erkennung erfolgt rein analytisch und deterministisch ohne schwere ML-Frameworks: Ein Frame besteht aus einem Render-Durchgang auf die Fensterfläche (`canvas.rs`):
### 3.1. Ramer-Douglas-Peucker (RDP) 1. Für jede sichtbare Nachbarseite: Papier und Muster, Bilder/PDF-Seite/Text, Tinte.
Reduziert den Rohpfad auf signifikante Eckpunkte mit konfigurierbarem Toleranzabstand $\epsilon$. 2. Für die aktuelle Seite: Papier, Muster (Karo, Linien, Punkte), Bildebene, gespeicherte Striche, Live-Strich.
3. Die `egui`-Oberfläche: Werkzeugleiste, Menüs, Auswahlrahmen und Griffe, Lineal, Textbearbeitung.
### 3.2. Erkennungsregeln: ### 4.1 Bildebene
- **Gerade:** Verhältnis von Sehnenlänge ($||P_{\text{end}} - P_{\text{start}}||$) zur Pfad-Bogenlänge $> 0.92$. `image_layer.rs` zeichnet texturierte Vierecke unter der Tinte: importierte PDF-Seiten, eingefügte Bilder und Textfelder. Texturen werden beim ersten Bedarf geladen, bekommen Mipmaps und werden freigegeben, wenn mehr als 384 MB belegt sind.
- **Rechteck:** RDP liefert 4 dominante Ecken; Kantenwinkel liegen zwischen $75^\circ$ und $105^\circ$. - **PDF-Seiten** rendert ein Hintergrund-Thread mit `hayro` (längste Kante 2560 Pixel, beim starken Hineinzoomen 5120 Pixel). Bis die schärfere Fassung fertig ist, bleibt die vorhandene sichtbar.
- **Kreis / Ellipse:** Pfad ist geschlossen ($||P_{\text{start}} - P_{\text{end}}|| < 0.1 \times \text{Umfang}$); Standardabweichung der Radien vom Mittelpunkt ist minimal ($< 0.12 \times \bar{r}$). - **Textfelder** werden mit `ab_glyph` gerastert, in einer Auflösung, die dem Zoom folgt (in Vierteln einer Zweierpotenz, höchstens 8 Pixel je Einheit). Der Texturschlüssel enthält einen Hash von Inhalt, Schrift und Farbe; jede Änderung erzeugt eine neue Textur.
- **Koordinatensystem:** Zwei orthogonale Schenkel mit Schnittpunkt nahe dem Start/Ende; Winkel $80^\circ$ bis $100^\circ$ wird exakt auf $90^\circ$ eingerastet; positive Achsenenden erhalten Pfeilspitzen.
- **Dreiecke / Rhomben / Oktaeder:** Spezifische Eckpunkt- und Symmetrieanalysen der RDP-Kontur.
### 3.3. Draw-and-Hold Erkennung ### 4.2 Papier- und Musterfarben
Hält der Anwender den Stift am Ende eines Strichs für $> 400\text{ ms}$ innerhalb eines Radius von $< 3\text{ px}$ ruhig, wird die Form im fliegenden Wechsel als Vektorform visualisiert und beim Abheben des Stifts fixiert. Papierfarbe (`PaperTheme`) und Musterfarbe (`PatternColor`) stehen in `background.rs`. Alle Farben sind linear; die Fensterfläche ist sRGB, die Umrechnung übernimmt die GPU. Auf dunklem Papier kann der Shader dunkle Tinte aufhellen (`ink_params` in `CameraUniforms`), gespeichert werden immer die Originalfarben.
--- ---
## 4. Persistenz & Autosave-Architektur (`storage.rs`, `autosave.rs`) ## 5. Objekte auf der Seite
Alles auf einer Seite ist ein `Stroke` (`document.rs`):
- **Tinte:** Abtastpunkte, Werkzeug (Stift, Textmarker, Form), Breite, Farbe; die Dreiecke werden beim Laden aus den Punkten neu berechnet.
- **Formen:** zusätzlich eine `RecognizedShape` (siehe Abschnitt 6).
- **Bilder und Textfelder:** vier Eckpunkte (oben links, oben rechts, unten rechts, unten links) plus Verweis auf die Bilddatei bzw. ein `TextBox`. Weil die Ecken normale Punkte sind, wirken Verschieben, Drehen und Skalieren der Auswahl ohne Sonderfälle.
### 5.1 Textfelder
`text.rs` setzt den Text selbst: Zeilenumbruch an Leerzeichen (zu lange Wörter zeichenweise), Unterschneidung, Tabulator = 4 Leerzeichen. Bildschirm-Textur, PDF-Export und Bearbeitung verwenden dieselben Schriften (Ubuntu Light und Hack aus den eingebauten `egui`-Schriften) und denselben Umbruch. Bearbeitet wird in einem `egui`-Textfeld an der Stelle des Felds; danach wird die Höhe des Felds an den Text angepasst, Lage, Drehung und Skalierung bleiben.
### 5.2 Rückgängig
Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ersetzen und Ändern von Strichen. Verschieben/Drehen/Skalieren einer Auswahl, ein Radierzug mit dem Teilstrich-Radierer und eine Textbearbeitung sind jeweils ein Schritt.
---
## 6. Formerkennung
`geometry.rs` erkennt Formen rein geometrisch, ohne Machine Learning. Geschlossene Formen (Kreis, Ellipse, Dreieck, Rechteck, Raute) treten gegeneinander an; gewählt wird die, deren Umriss am besten zu den Punkten passt. Danach folgen Vielecke, Pfeile, Koordinatensysteme und Linien. Übernommen wird eine Form nur, wenn der Strich im Mittel höchstens 4 % seiner Diagonale von ihr abweicht – sonst bleibt er Handschrift.
- **Linie:** Verhältnis von Abstand Anfang–Ende zur Strichlänge über 0,92.
- **Kreis, Ellipse:** Kreis- bzw. Ellipsen-Fit über die Punkte.
- **Rechteck, Dreieck, Raute, Vieleck, Pfeil:** Analyse der mit Douglas-Peucker vereinfachten Kontur (Eckenzahl, Winkel, Seitenverhältnisse).
- **Koordinatensystem:** zwei etwa rechtwinklige Achsen; der Winkel wird auf 90° gesetzt.
- **Oktaeder:** Skizze einer Doppelpyramide als 3D-Kantenmodell; nur, wenn kein einfacher Umriss passt.
**Halten am Strichende:** Bleibt der Stift über 400 ms innerhalb von 3 Pixeln ruhig, wird die erkannte Form schon vor dem Absetzen angezeigt und beim Absetzen übernommen. Mit dem Formen-Werkzeug wird jeder Strich beim Absetzen geprüft.
---
## 7. Speichern
``` ```
[UI / Inking Event] [Änderung]
│ │ mark_page_dirty / mark_manifest_dirty
▼ ▼
[mark_page_dirty] [AutosaveController] ── 1,5 s nach der letzten Änderung, spätestens nach 10 s ──► [Autosave-Thread]
│ │
▼ ▼
[AutosaveController] ──(1.5s Debounce)──► [Hintergrund-Thread (MPSC)] [temporäre Datei schreiben + fsync]
│ │
▼ ▼
[Temp-Datei *.tmp schreiben] [rename über die alte Datei, Ordner-fsync]
│
▼
[Atomares std::fs::rename]
│
▼
[manifest.json / page_<id>.json]
``` ```
- **Keine UI-Blockierung:** Disk-I/O erfolgt vollständig in einem dedizierten Worker-Thread. - Der Haupt-Thread schreibt nie selbst; er übergibt fertige Seitendaten an den Autosave-Thread.
- **Atomarität:** Daten werden stets zuerst in `.json.tmp` geschrieben und via `fs::rename` atomar ersetzt. - Schlägt ein Schreibvorgang fehl, wird die Seite mit wachsendem Abstand (1 s bis 30 s) erneut versucht; der Status wird rot.
- **Verlustfreier Notizbuchwechsel:** Bei `switch_notebook_dir` wird synchron `flush()` aufgerufen, um alle ungespeicherten Daten des aktuellen Dokuments sicherzustellen, bevor der Pfad gewechselt wird. - `flush()` schreibt alles Ausstehende und wartet darauf. Es läuft bei `Strg + S`, beim Wechsel des Notizbuchs und beim Beenden. Schlägt es beim Beenden fehl, bleibt die App offen und bietet Wiederholen oder einen Notfall-Export der ungespeicherten Seiten an (`~/.local/share/ink-notes/recovery/`).
- Seiten werden erst beim Anzeigen geladen (Lazy Loading); Titel, Hintergrund und Größe stehen im Manifest, damit Seitenübersicht und durchgehende Ansicht sie vorher kennen.
- Ein zweites Fenster mit demselben Notizbuch öffnet es schreibgeschützt (Sperrdatei).
---
## 8. Export
`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen:
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier).
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade.
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
PNG und SVG einer Seite (`page_export.rs`) entstehen aus diesem PDF: `hayro` rastert es bzw. wandelt es in SVG um. So sehen alle Exporte gleich aus.
---
## 9. Updates
`updater.rs` fragt die Gitea-Releases ab, vergleicht die Version (SemVer) und lädt auf Wunsch das passende Paket. Vor der Installation prüft er die Minisign-Signatur von `SHA256SUMS.txt` gegen den eingebauten Schlüssel und danach die SHA-256-Prüfsumme des Pakets. Ein AppImage wird per `rename` ersetzt; ein `.deb`-Paket wird mit `xdg-open` an die Softwareinstallation des Systems übergeben. Details im README unter „Sicherheit der Updates“.
+131 -95
View File
@@ -1,137 +1,173 @@
# Dateiformat & Speicherstruktur von ink-notes # Dateiformat von ink-notes
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt. Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die Versionen des Dateiformats. Aktuell ist **Version 8**.
--- ---
## 1. Verzeichnisstruktur ## 1. Ordner
Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
```text ```text
~/.local/share/ink-notes/ ~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes)
└── notebooks/ ├── notebooks/
├── <notebook_id_1>/ │ ├── <notizbuch-id>/
│ ├── manifest.json │ │ ├── manifest.json Metadaten und Seitenreihenfolge
│ ├── manifest.json.v0.bak (optional, Migrations-Backup) │ │ ├── manifest.json.bak Kopie des vorherigen Manifests
│ ├── page_1.json │ │ ├── page_1.json eine Datei je Seite
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern) │ │ ├── page_2.json
│ ├── page_2.json │ │ ├── page_7.json.corrupt-<ts> beiseitegelegte beschädigte Seite (falls vorgekommen)
│ └── ... │ │ ├── assets/ eingefügte Bilder und importierte PDFs
└── <notebook_id_2>/ │ │ │ ├── <sha256>.png
├── manifest.json │ │ │ └── <sha256>.pdf
└── page_1.json │ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
``` ```
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
--- ---
## 2. Dokumenten-Manifest (`manifest.json`) ## 2. Manifest (`manifest.json`)
Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge und IDs aller Seiten:
```json ```json
{ {
"format_version": 2, "format_version": 8,
"id": "notebook-a1b2c3d4", "id": "vorlesung_1775460000",
"title": "Mathematik Vorlesung", "title": "Vorlesung",
"created_at": 1775460000,
"modified_at": 1775463600,
"current_page_index": 0,
"pages": [ "pages": [
{ { "id": 1, "background": "Blank", "title": "Einführung", "size": [2970.0, 1671.0] },
"id": 1, { "id": 2, "background": "Grid5mm" }
"background": "Grid5mm"
},
{
"id": 2,
"background": "DotGrid"
}
], ],
"page_ids": [1, 2], "page_ids": [1, 2],
"current_page_index": 0,
"next_page_id": 3,
"next_stroke_id": 42, "next_stroke_id": 42,
"created_at": 1775460000, "next_page_id": 3
"modified_at": 1775463600
} }
``` ```
### Felder: | Feld | Bedeutung |
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`). | :--- | :--- |
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`). | `format_version` | Formatversion (siehe Abschnitt 5) |
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs. | `id`, `title` | ID (= Ordnername) und Anzeigename |
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`). | `created_at`, `modified_at` | UNIX-Zeit in Sekunden |
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs. | `current_page_index` | zuletzt geöffnete Seite |
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore. | `pages` | Seiten in ihrer Reihenfolge: `id`, `background`, optional `title` und `size` (siehe unten) – damit Seitenübersicht und Ansicht sie kennen, ohne jede Seite zu laden |
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten. | `page_ids` | dieselbe Reihenfolge als reine ID-Liste (für ältere Versionen) |
- `next_stroke_id`: Globaler ID-Zähler für neue Striche. | `next_stroke_id`, `next_page_id` | nächste freie IDs |
- `created_at` / `modified_at`: UNIX-Zeitstempel in Sekunden.
--- ---
## 3. Seitendatei (`page_<id>.json`) ## 3. Seite (`page_<id>.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen. Jede Seite liegt in einer eigenen Datei (kompaktes JSON, hier zur Lesbarkeit umbrochen):
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}]} {
"format_version": 8,
"id": 1,
"background": "Blank",
"title": "Einführung",
"size": [2970.0, 1671.0],
"pdf_background": { "asset": "<sha256>.pdf", "page": 0, "width_pt": 960.0, "height_pt": 540.0 },
"strokes": [ … ]
}
``` ```
### Felder: | Feld | Bedeutung |
- `format_version`: Format-Versionsnummer (`2`). | :--- | :--- |
- `id`: Eindeutige ID der Seite. | `background` | Muster: `Blank`, `Grid5mm`, `Lined`, `DotGrid` |
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`). | `title` | Seitentitel (optional) |
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen. | `size` | Seitengröße `[breite, höhe]` in Seiteneinheiten (10 = 1 mm); fehlt bei DIN A4 hoch (`[2100, 2970]`) |
- `id`: Eindeutige ID des Strichs. | `pdf_background` | importierte PDF-Seite als Hintergrund (optional): Datei, Seitennummer ab 0, Größe in Punkt |
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`. | `strokes` | alle Objekte der Seite, von unten nach oben |
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
- `shape`: Optionales Enum für geometrische Vektorformen: ### 3.1 Objekte (`strokes`)
- `Line { start, end }`
- `Rectangle { top_left, width, height }` Jedes Objekt hat eine `id`, eine `color` (RGBA, linear, Werte 0–1) und `samples`: Punkte mit `x`, `y` (Seiteneinheiten), `p` (Druck 0–1) und `t` (Zeit in Sekunden). Die Dreiecke für die Anzeige werden beim Laden aus den Punkten berechnet und nicht gespeichert.
- `Circle { center, radius }`
- `Axes { origin, x_end, y_end, has_arrows }` **Tinte**
- `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }` ```json
- `Octahedron { center, radius }` { "id": 7, "color": [0.12, 0.12, 0.14, 1.0],
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`). "samples": [{ "x": 350.2, "y": 420.5, "p": 0.65, "t": 12.345 }, …],
- `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). "tool": "pen", "width": 5.0, "finalized": true }
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben. ```
- `tool`: `pen`, `highlighter` oder `shape`
- `width`: Strichbreite in Seiteneinheiten (5.0 = 0,5 mm)
- `finalized`: Der Strich wurde nach dem Absetzen nachbearbeitet (Glättung, Ecken, Endkappen)
**Formen** haben zusätzlich `shape`, z. B.:
```json
"shape": { "Rectangle": { "corners": [[500, 600], [800, 600], [800, 800], [500, 800]] } }
```
Mögliche Formen: `Line { start, end }`, `Arrow { start, end, double }`, `Circle { center, radius }`, `Ellipse { center, radius_x, radius_y, rotation }`, `Rectangle { corners }`, `Triangle { corners }`, `Rhombus { corners }`, `Polygon { vertices }`, `CoordinateSystem { origin, x_axis_end, y_axis_end, with_arrows }`, `Octahedron { … }`. Punkte sind `[x, y]`.
**Bilder** haben `image` (Dateiname in `assets/`); die vier `samples` sind die Ecken oben links, oben rechts, unten rechts, unten links:
```json
{ "id": 9, "color": [1, 1, 1, 1], "samples": [ …4 Ecken… ], "width": 0.0, "image": "<sha256>.png" }
```
**Textfelder** haben `text`; die vier `samples` sind wie bei Bildern die Ecken des Felds:
```json
"text": { "content": "Überschrift\nzweite Zeile", "size": 42.3, "font": "Sans", "wrap_width": 1500.0 }
```
- `size`: Schriftgröße (Em) in Seiteneinheiten (12 pt ≈ 42,3)
- `font`: `Sans` (Ubuntu Light) oder `Mono` (Hack)
- `wrap_width`: Umbruchbreite in Seiteneinheiten; die Ecken bilden das Feld dieser Breite (und der Höhe des gesetzten Texts) ab, Drehung und Skalierung stecken in den Ecken
--- ---
## 4. Datensicherheit & Migration ## 4. Sicherheit beim Speichern
### Atomares Schreiben & Verzeichnis-Sync ### Atomares Schreiben
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix). 1. Der Inhalt wird in `<datei>.tmp.<pid>.<nanosekunden>` geschrieben und mit `fsync` auf den Datenträger gebracht.
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt. 2. `rename` ersetzt die alte Datei in einem Schritt.
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`. 3. Danach wird auch der Ordner mit `fsync` gesichert.
### Quarantäne bei Parsefehlern Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder die neue Datei, nie eine halbe.
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 ### Beschädigte Dateien
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`). - Lässt sich eine Seite nicht lesen, wird sie nach `page_<id>.json.corrupt-<zeitstempel>` verschoben (nicht gelöscht) und die Seite schreibgeschützt angezeigt.
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird. - Ist `manifest.json` beschädigt oder fehlt, wird `manifest.json.bak` verwendet.
### Neuere Versionen
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
### Migration
Beim ersten Speichern einer unversionierten Datei (Version 0) wird vorher eine Kopie `*.v0.bak` angelegt.
--- ---
## 5. Versionshistorie ## 5. Versionen
| Version | Status | Beschreibung | | Version | App | Änderung |
|---|---|---| | :--- | :--- | :--- |
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. | | 0 | – | ohne `format_version` |
| **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`. | | 1 | – | `format_version`, Hintergründe im Manifest |
| **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). | | 2 | – | `tool`, `width`, `finalized` an Strichen; kompaktes JSON |
| 3 | 0.3.7 | Formen `Ellipse` und `Arrow` |
| 4 | 0.5 | Bilder (`image`, Ordner `assets/`) |
| 5 | 0.6.1 | importierte PDF-Seiten (`pdf_background`) |
| 6 | 0.7 | Seitentitel (`title` in Seite und Manifest) |
| 7 | 0.10 | Textfelder (`text`) |
| 8 | 0.11 | Seitengröße je Seite (`size` in Seite und Manifest) |
Jede neue Version ändert nur Ergänzungen; ältere Dateien werden ohne Umwandlung gelesen und beim nächsten Speichern mit der aktuellen Versionsnummer geschrieben.
--- ---
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`) ## 6. Warum JSON
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert: Ein Binärformat (z. B. `postcard`) würde Dateien um weitere 20–30 % verkleinern. JSON bleibt trotzdem, weil es sich ansehen, vergleichen und im Notfall von Hand reparieren lässt und das Speichern einer Seite auch bei vielen Strichen deutlich unter einer Millisekunde dauert.
- **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.
+2 -2
View File
@@ -2,13 +2,13 @@
Version=1.0 Version=1.0
Name=ink-notes Name=ink-notes
GenericName=Notizbuch- & Inking-App GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
Exec=ink-notes %F Exec=ink-notes %F
Icon=eu.pansi.ink-notes Icon=eu.pansi.ink-notes
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Office;Graphics;Utility; Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector; Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes StartupWMClass=ink-notes
MimeType=application/x-inknotes; MimeType=application/x-inknotes;
Actions=NewWindow; Actions=NewWindow;
+12 -10
View File
@@ -4,20 +4,22 @@
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
<name>ink-notes</name> <name>ink-notes</name>
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary> <summary>Notizbuch-App für Stift, Touch und Maus</summary>
<description> <description>
<p> <p>
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux, ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus. Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
</p> </p>
<p>Hauptmerkmale:</p> <p>Funktionen:</p>
<ul> <ul>
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li> <li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li> <li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li> <li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li> <li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li> <li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
<li>Vektor-PDF-Export für Druck und Weitergabe</li> <li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
</ul> </ul>
</description> </description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable> <launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
+2 -2
View File
@@ -2,13 +2,13 @@
Version=1.0 Version=1.0
Name=ink-notes Name=ink-notes
GenericName=Notizbuch- & Inking-App GenericName=Notizbuch- & Inking-App
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
Exec=ink-notes %F Exec=ink-notes %F
Icon=eu.pansi.ink-notes Icon=eu.pansi.ink-notes
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Office;Graphics;Utility; Categories=Office;Graphics;Utility;
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector; Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
StartupWMClass=ink-notes StartupWMClass=ink-notes
MimeType=application/x-inknotes; MimeType=application/x-inknotes;
Actions=NewWindow; Actions=NewWindow;
+12 -10
View File
@@ -4,20 +4,22 @@
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
<name>ink-notes</name> <name>ink-notes</name>
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary> <summary>Notizbuch-App für Stift, Touch und Maus</summary>
<description> <description>
<p> <p>
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux, ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus. Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
</p> </p>
<p>Hauptmerkmale:</p> <p>Funktionen:</p>
<ul> <ul>
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li> <li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li> <li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li> <li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li> <li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li> <li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
<li>Vektor-PDF-Export für Druck und Weitergabe</li> <li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
</ul> </ul>
</description> </description>
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable> <launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
+11 -10
View File
@@ -1,8 +1,9 @@
## ink-notes {{TAG_NAME}} ## ink-notes {{TAG_NAME}}
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04). Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### 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) - **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 - **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 - **Virtuelles Lineal**: Ein- und ausblenden mit L oder über die Werkzeugleiste; verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden exakt gerade – mit Stift, Textmarker und Formwerkzeug
@@ -13,7 +14,7 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht) - **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) - **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 - **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer - **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Vorschau, zum Anpassen an den eigenen Stift
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht) - **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 - **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` - **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
@@ -21,21 +22,21 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert - **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern - **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung - **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)**: Stifteingabe über das Tablet-Protokoll von Wayland bzw. X11, mit Druckstärke
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus - **Touchscreen Multi-Touch**: Zoomen mit zwei Fingern, Verschieben mit einem; Zeichnen mit dem Finger zuschaltbar
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation) - **Zwei-Stufen-Inking-Modell**: Glättung während des Schreibens (1€-Filter), beim Absetzen Nachbearbeitung mit Ecken-Erhaltung
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert - **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups - **Mehrere Notizbücher**: Anlegen, umbenennen, löschen (mit Papierkorb); automatisches Speichern im Hintergrund
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern - **Datensicherheit**: Versioniertes Dateiformat mit Migration, beschädigte Seiten werden beiseitegelegt statt überschrieben, Schutz vor gleichzeitigem Öffnen, atomares Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS - **In-App Updater**: Prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch – nur mit gültiger Minisign-Signatur und über HTTPS
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe - **PDF-Export**: Striche und Formen als Vektoren, für Druck und Weitergabe
### Installation: ### Installation:
- **Zorin OS / Ubuntu / Debian (.deb)**: - **Zorin OS / Ubuntu / Debian (.deb)**:
```bash ```bash
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
``` ```
- **Portables AppImage (Alle Linux-Distributionen)**: - **AppImage (andere Distributionen mit glibc ≥ 2.39)**:
```bash ```bash
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
./ink-notes_{{VERSION}}_x86_64.AppImage ./ink-notes_{{VERSION}}_x86_64.AppImage
+5 -7
View File
@@ -104,9 +104,7 @@ cat <<EOF > "${CHANGELOG_TMP}"
ink-notes (${VERSION}-1) stable; urgency=medium ink-notes (${VERSION}-1) stable; urgency=medium
* Release v${VERSION}. * Release v${VERSION}.
* Low-latency vector inking with two-stage pipeline. * See the release notes on https://gitea.pansi.eu/harald/ink-notes/releases
* Shape recognition and infinite canvas with zoom/pan.
* Multi-notebook management and PDF export.
-- Harald <harald@pansi.eu> $(date -R) -- Harald <harald@pansi.eu> $(date -R)
EOF EOF
@@ -130,10 +128,10 @@ Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-ic
Section: utils Section: utils
Priority: optional Priority: optional
Homepage: https://gitea.pansi.eu/harald/ink-notes Homepage: https://gitea.pansi.eu/harald/ink-notes
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux Description: Notizbuch-App für Stift, Touch und Maus
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu), ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen und
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen, kann PDFs zum Beschriften importieren sowie Bilder und Textfelder einfügen.
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export. Mehrere Notizbücher, automatisches Speichern und Export als PDF, PNG oder SVG.
EOF EOF
chmod 644 "${STAGING_DIR}/DEBIAN/control" chmod 644 "${STAGING_DIR}/DEBIAN/control"
+119 -27
View File
@@ -3,12 +3,65 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex; use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; use crate::document::PageBackground;
/// Farbe von Karos, Linien und Punkten (wird gespeichert und gilt auch für den Export).
/// `Light` ist die zurückhaltende Standardfarbe des jeweiligen Papiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PatternColor {
#[default]
Light,
Medium,
Strong,
Blue,
Green,
}
impl PatternColor {
pub const ALL: [PatternColor; 5] = [
PatternColor::Light,
PatternColor::Medium,
PatternColor::Strong,
PatternColor::Blue,
PatternColor::Green,
];
pub fn label(self) -> &'static str {
match self {
PatternColor::Light => "Hell",
PatternColor::Medium => "Mittel",
PatternColor::Strong => "Kräftig",
PatternColor::Blue => "Blau",
PatternColor::Green => "Grün",
}
}
/// Linearer Farbwert des Musters auf dem Papier `paper` (für den Export: weißes Papier).
pub fn color(self, paper: PaperTheme) -> [f32; 4] {
let light = paper.pattern_color();
let dark = paper.is_dark();
let scaled = |f: f32| [light[0] * f, light[1] * f, light[2] * f, 1.0];
match (self, dark) {
(PatternColor::Light, _) => light,
// Helles Papier: kräftiger = dunkler
(PatternColor::Medium, false) => scaled(0.62),
(PatternColor::Strong, false) => scaled(0.28),
// Dunkles Papier: kräftiger = heller
(PatternColor::Medium, true) => [0.16, 0.16, 0.17, 1.0],
(PatternColor::Strong, true) => [0.32, 0.32, 0.34, 1.0],
(PatternColor::Blue, false) => [0.35, 0.55, 0.95, 1.0],
(PatternColor::Blue, true) => [0.06, 0.14, 0.42, 1.0],
(PatternColor::Green, false) => [0.32, 0.72, 0.38, 1.0],
(PatternColor::Green, true) => [0.05, 0.22, 0.09, 1.0],
}
}
}
/// Linienstärke für Gitter- und Schreiblinien in Seiteneinheiten (1.5 = 0.15 mm). /// 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;
/// Punktgröße für das Punktraster in Seiteneinheiten (2.5 = 0.25 mm). /// Punktgröße für das Punktraster in Seiteneinheiten (4 = 0,4 mm, wie im PDF-Export).
pub const DOT_SIZE: f32 = 2.5; pub const DOT_SIZE: f32 = 4.0;
/// Rasterabstand 5 mm (50 Einheiten). /// Rasterabstand 5 mm (50 Einheiten).
pub const GRID_5MM_SPACING: f32 = 50.0; pub const GRID_5MM_SPACING: f32 = 50.0;
@@ -99,15 +152,16 @@ pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]] [color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
} }
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt. /// Erzeugt die 6 Vertizes (2 Dreiecke) für ein Blatt der Größe `size`.
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> { 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, color), InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, color), InkVertex::new(page_width, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, color), InkVertex::new(0.0, page_height, color),
InkVertex::new(0.0, PAGE_HEIGHT, color), InkVertex::new(0.0, page_height, color),
InkVertex::new(PAGE_WIDTH, 0.0, color), InkVertex::new(page_width, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color), InkVertex::new(page_width, page_height, color),
] ]
} }
@@ -129,8 +183,14 @@ fn push_quad(
vertices.push(InkVertex::new(x_max, y_max, color)); vertices.push(InkVertex::new(x_max, y_max, color));
} }
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`. /// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color` auf
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> 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 => {
@@ -139,27 +199,27 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
// 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( push_quad(
&mut vertices, &mut vertices,
color, color,
x - half_t, x - half_t,
0.0, 0.0,
x + half_t, x + half_t,
PAGE_HEIGHT, 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( push_quad(
&mut vertices, &mut vertices,
color, color,
0.0, 0.0,
y - half_t, y - half_t,
PAGE_WIDTH, page_width,
y + half_t, y + half_t,
); );
y += GRID_5MM_SPACING; y += GRID_5MM_SPACING;
@@ -176,13 +236,13 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
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( push_quad(
&mut vertices, &mut vertices,
color, color,
0.0, 0.0,
y - half_t, y - half_t,
PAGE_WIDTH, page_width,
y + half_t, y + half_t,
); );
y += LINED_SPACING; y += LINED_SPACING;
@@ -195,9 +255,9 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
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, color,
@@ -220,22 +280,54 @@ pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) ->
mod tests { mod tests {
use super::*; use super::*;
use crate::document::{A4_SIZE, PAGE_HEIGHT, PAGE_WIDTH};
#[test]
fn test_pattern_colors_get_more_visible() {
// Luminanz (linear) als Abstand zum Papier
let lum = |c: [f32; 4]| 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
for paper in PaperTheme::ALL {
let p = lum(paper.paper_color());
let contrast = |c: PatternColor| (lum(c.color(paper)) - p).abs();
assert_eq!(PatternColor::Light.color(paper), paper.pattern_color());
assert!(
contrast(PatternColor::Medium) > contrast(PatternColor::Light),
"{paper:?}"
);
assert!(
contrast(PatternColor::Strong) > contrast(PatternColor::Medium),
"{paper:?}"
);
for c in [PatternColor::Blue, PatternColor::Green] {
assert!(
contrast(c) > contrast(PatternColor::Light),
"{c:?} auf {paper:?}"
);
}
}
}
#[test] #[test]
fn test_paper_quad_generation() { fn test_paper_quad_generation() {
let quad = generate_paper_quad(PaperTheme::White.paper_color()); 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]; 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, PATTERN); 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, PATTERN); 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,
@@ -243,11 +335,11 @@ 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, PATTERN); 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, PATTERN); 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);
} }
@@ -255,9 +347,9 @@ mod tests {
#[test] #[test]
fn test_background_mesh_uses_theme_color() { fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color(); let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark); let mesh = generate_background_mesh(PageBackground::Lined, dark, A4_SIZE);
assert!(mesh.iter().all(|v| v.color == dark)); assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color()); let quad = generate_paper_quad(PaperTheme::Cream.paper_color(), A4_SIZE);
assert!(quad assert!(quad
.iter() .iter()
.all(|v| v.color == PaperTheme::Cream.paper_color())); .all(|v| v.color == PaperTheme::Cream.paper_color()));
+112 -54
View File
@@ -1,7 +1,7 @@
//! GPU-Canvas-Renderpipeline mit wgpu und Low-Latency-Verwaltung. //! GPU-Canvas-Renderpipeline mit wgpu und Low-Latency-Verwaltung.
//! //!
//! Features: //! Features:
//! - Zero-Latency-Pufferung via `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`. //! - Geringe Bildverzögerung durch `PresentMode::Mailbox` (Fallback: `Fifo`) und `desired_maximum_frame_latency: 1`.
//! - Strikte Pipeline-Trennung: DIN-A4-Hintergrund, permanente Striche und temporäre Live-Geometrie. //! - Strikte Pipeline-Trennung: DIN-A4-Hintergrund, permanente Striche und temporäre Live-Geometrie.
//! - 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`).
@@ -12,10 +12,11 @@ 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, PaperTheme}; use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme, PatternColor};
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)]
@@ -119,18 +120,22 @@ pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::Pres
struct NeighborFingerprint { struct NeighborFingerprint {
page_id: u64, page_id: u64,
background: PageBackground, background: PageBackground,
size: [u32; 2],
paper_theme: PaperTheme, paper_theme: PaperTheme,
pattern: PatternColor,
vertex_count: usize, vertex_count: usize,
stroke_count: usize, stroke_count: usize,
last_stroke_id: Option<u64>, last_stroke_id: Option<u64>,
} }
impl NeighborFingerprint { impl NeighborFingerprint {
fn of(page: &Page, paper_theme: PaperTheme) -> Self { fn of(page: &Page, paper_theme: PaperTheme, pattern: PatternColor) -> Self {
Self { Self {
page_id: page.id, page_id: page.id,
background: page.background, background: page.background,
size: page.size.to_array().map(f32::to_bits),
paper_theme, paper_theme,
pattern,
vertex_count: page.vertex_cache.len(), vertex_count: page.vertex_cache.len(),
stroke_count: page.strokes.len(), stroke_count: page.strokes.len(),
last_stroke_id: page.strokes.last().map(|s| s.id), last_stroke_id: page.strokes.last().map(|s| s.id),
@@ -141,8 +146,8 @@ impl NeighborFingerprint {
/// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite. /// GPU-Daten einer in der durchgehenden Ansicht sichtbaren Nachbarseite.
struct NeighborPage { struct NeighborPage {
fingerprint: NeighborFingerprint, fingerprint: NeighborFingerprint,
/// Lage relativ zur aktuellen Seite (in Seiten, negativ = darüber) /// Ecke oben links in Koordinaten der aktuellen Seite
offset: i64, offset: glam::Vec2,
camera_buffer: wgpu::Buffer, camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup, camera_bind_group: wgpu::BindGroup,
background_buffer: Option<wgpu::Buffer>, background_buffer: Option<wgpu::Buffer>,
@@ -187,6 +192,11 @@ pub struct CanvasState {
pub paper_theme: PaperTheme, pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen /// Dunkle Tinte auf dunklem Papier aufhellen
pub adapt_ink: bool, pub adapt_ink: bool,
/// Farbe von Karos, Linien und Punkten
pattern: PatternColor,
/// Größe der aktuellen Seite und Rahmen, in den alle Seiten des Notizbuchs passen
page_size: glam::Vec2,
fit_box: glam::Vec2,
/// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung) /// Textfelder der sichtbaren Seiten nach Texturschlüssel (für die Rasterung)
text_specs: HashMap<String, TextSpec>, text_specs: HashMap<String, TextSpec>,
/// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung /// Gerade bearbeitetes Textfeld der aktuellen Seite: nicht zeichnen (die Bearbeitung
@@ -293,7 +303,7 @@ impl CanvasState {
) )
.await?; .await?;
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1 // 4. Geringe Bildverzögerung (Mailbox, sonst Fifo) & Frame-Latency 1
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
.iter() .iter()
@@ -431,7 +441,7 @@ impl CanvasState {
// 6. Statische Buffer // 6. Statische Buffer
let paper_theme = PaperTheme::default(); let paper_theme = PaperTheme::default();
let paper_quad = generate_paper_quad(paper_theme.paper_color()); 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,
@@ -441,7 +451,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, paper_theme.pattern_color()); 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,
@@ -499,6 +513,9 @@ impl CanvasState {
current_background, current_background,
paper_theme, paper_theme,
adapt_ink: false, adapt_ink: false,
pattern: PatternColor::default(),
page_size: crate::document::A4_SIZE,
fit_box: crate::document::A4_SIZE,
text_specs: HashMap::new(), text_specs: HashMap::new(),
hidden_text: None, hidden_text: None,
text_resolution: 1.0, text_resolution: 1.0,
@@ -530,7 +547,7 @@ 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 camera_uniforms = self.camera_uniforms(0); let camera_uniforms = self.camera_uniforms(glam::Vec2::ZERO);
self.queue.write_buffer( self.queue.write_buffer(
&self.camera_buffer, &self.camera_buffer,
0, 0,
@@ -545,10 +562,11 @@ impl CanvasState {
} }
} }
/// Kamera für die Seite `page_offset` Seiten unter (negativ: über) der aktuellen. /// Kamera für eine Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite).
fn camera_uniforms(&self, page_offset: i64) -> CameraUniforms { fn camera_uniforms(&self, origin: glam::Vec2) -> CameraUniforms {
let (scale, mut offset) = self.camera.ndc_transform(&self.viewport); let (scale, mut offset) = self.camera.ndc_transform(&self.viewport);
offset[1] += page_offset as f32 * crate::viewport::PAGE_PITCH * scale[1]; offset[0] += origin.x * scale[0];
offset[1] += origin.y * scale[1];
CameraUniforms { CameraUniforms {
transform_scale: scale, transform_scale: scale,
transform_offset: offset, transform_offset: offset,
@@ -577,13 +595,22 @@ impl CanvasState {
true 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) { fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets( self.viewport = PageViewport::calculate_for(
self.size.width as f32, self.size.width as f32,
self.size.height as f32, self.size.height as f32,
Self::VIEWPORT_MARGIN, Self::VIEWPORT_MARGIN,
self.ui_inset_left, (self.ui_inset_left, self.ui_inset_right),
self.ui_inset_right, self.fit_box,
self.page_size,
); );
self.update_uniforms(); self.update_uniforms();
} }
@@ -653,60 +680,60 @@ impl CanvasState {
self.camera.page_to_screen_vec2(page_pt, &self.viewport) self.camera.page_to_screen_vec2(page_pt, &self.viewport)
} }
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite unter dem Bildschirmpunkt, /// Durchgehende Ansicht: Seite unter dem Bildschirmpunkt, `None` neben den Seiten und
/// `None` neben den Seiten und zwischen zwei Seiten. /// zwischen zwei Seiten.
pub fn page_offset_at_screen(&self, screen_x: f32, screen_y: f32) -> Option<i64> { pub fn page_at_screen(&self, stack: &PageStack, screen_x: f32, screen_y: f32) -> Option<usize> {
let p = self let p = self
.camera .camera
.screen_to_page_unclamped(screen_x, screen_y, &self.viewport); .screen_to_page_unclamped(screen_x, screen_y, &self.viewport);
if !(0.0..=crate::document::PAGE_WIDTH).contains(&p.x) { let index = stack.page_at(p.y)?;
return None; let x = p.x - stack.origins[index].x;
} (0.0..=stack.sizes[index].x).contains(&x).then_some(index)
crate::viewport::page_offset_at(p.y)
} }
/// Durchgehende Ansicht: Versatz (in Seiten) der Seite in der Mitte des Fensters. /// Durchgehende Ansicht: Seite auf Höhe der Fenstermitte.
pub fn page_offset_at_view_center(&self) -> Option<i64> { pub fn page_at_view_center(&self, stack: &PageStack) -> Option<usize> {
let p = self.camera.screen_to_page_unclamped( let p = self.camera.screen_to_page_unclamped(
0.0, 0.0,
self.size.height as f32 / 2.0, self.size.height as f32 / 2.0,
&self.viewport, &self.viewport,
); );
crate::viewport::page_offset_at(p.y) stack.page_at(p.y)
} }
/// Durchgehende Ansicht: Versätze der sichtbaren Nachbarseiten der aktuellen Seite. /// Durchgehende Ansicht: sichtbare Nachbarseiten der aktuellen Seite.
pub fn visible_neighbor_offsets(&self, current: usize, page_count: usize) -> Vec<i64> { pub fn visible_neighbors(&self, stack: &PageStack) -> Vec<usize> {
let top = self let top = self
.camera .camera
.screen_to_page_unclamped(0.0, 0.0, &self.viewport); .screen_to_page_unclamped(0.0, 0.0, &self.viewport);
let bottom = let bottom =
self.camera self.camera
.screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport); .screen_to_page_unclamped(0.0, self.size.height as f32, &self.viewport);
crate::viewport::visible_neighbor_offsets(top.y, bottom.y, current, page_count) stack.visible_neighbors(top.y, bottom.y)
} }
/// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der /// Durchgehende Ansicht: Hält die Fenstermitte über dem Notizbuch (von der Oberkante der
/// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seitenbreite), damit /// ersten bis zur Unterkante der letzten Seite, waagerecht innerhalb der Seiten), damit
/// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde. /// man nicht ins Leere scrollt. `true`, wenn der Ausschnitt korrigiert wurde.
pub fn clamp_scroll(&mut self, current: usize, page_count: usize) -> bool { pub fn clamp_scroll(&mut self, stack: &PageStack) -> bool {
let center = [ let center = [
self.viewport.page_x + self.viewport.page_width / 2.0, self.viewport.page_x + self.viewport.page_width / 2.0,
self.size.height as f32 / 2.0, self.size.height as f32 / 2.0,
]; ];
let changed = let changed = self
self.camera .camera
.clamp_continuous_center(center, current, page_count, &self.viewport); .clamp_continuous_center(center, stack, &self.viewport);
if changed { if changed {
self.update_uniforms(); self.update_uniforms();
} }
changed changed
} }
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich /// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der aktuellen Seite)
/// der Bildausschnitt bewegt. Die Nachbarseiten sind danach neu zu setzen. /// wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Danach ist die neue
pub fn shift_reference_page(&mut self, k: i64) { /// Seite mit [`Self::sync_page`] zu setzen.
self.camera.shift_reference_page(k); pub fn shift_reference_page(&mut self, origin: glam::Vec2) {
self.camera.shift_reference_page(origin);
self.update_uniforms(); self.update_uniforms();
} }
@@ -756,13 +783,21 @@ impl CanvasState {
self.queue.write_buffer( self.queue.write_buffer(
&self.paper_buffer, &self.paper_buffer,
0, 0,
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())), bytemuck::cast_slice(&generate_paper_quad(theme.paper_color(), self.page_size)),
); );
self.upload_background_mesh(); self.upload_background_mesh();
} }
self.update_uniforms(); self.update_uniforms();
} }
/// Setzt die Farbe von Karos, Linien und Punkten.
pub fn set_pattern_color(&mut self, pattern: PatternColor) {
if self.pattern != pattern {
self.pattern = pattern;
self.upload_background_mesh();
}
}
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
pub fn set_background(&mut self, background: PageBackground) { pub fn set_background(&mut self, background: PageBackground) {
if self.current_background == background { if self.current_background == background {
@@ -774,8 +809,11 @@ impl CanvasState {
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU. /// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
fn upload_background_mesh(&mut self) { fn upload_background_mesh(&mut self) {
let mesh = let mesh = generate_background_mesh(
generate_background_mesh(self.current_background, self.paper_theme.pattern_color()); self.current_background,
self.pattern.color(self.paper_theme),
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 {
@@ -799,6 +837,19 @@ impl CanvasState {
page.rebuild_cache_if_dirty(); page.rebuild_cache_if_dirty();
// Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen // Die aktuelle Seite kann sich jetzt ändern: als Nachbarseite später neu hochladen
self.neighbors.retain(|n| n.fingerprint.page_id != page.id); 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); let quads = self.page_image_quads(page, self.hidden_text);
self.image_layer.set_quads(quads); self.image_layer.set_quads(quads);
@@ -832,11 +883,11 @@ impl CanvasState {
/// aktuellen Seite (in Seiten). Die Vertex-Caches der Seiten müssen aktuell sein /// 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. /// ([`Page::rebuild_cache_if_dirty`]); unveränderte Seiten werden nicht erneut hochgeladen.
/// Eine leere Liste schaltet die Nachbarseiten ab. /// Eine leere Liste schaltet die Nachbarseiten ab.
pub fn set_neighbor_pages(&mut self, pages: &[(i64, &Page)]) { pub fn set_neighbor_pages(&mut self, pages: &[(glam::Vec2, &Page)]) {
let mut old = std::mem::take(&mut self.neighbors); let mut old = std::mem::take(&mut self.neighbors);
let mut image_groups = Vec::with_capacity(pages.len()); let mut image_groups = Vec::with_capacity(pages.len());
for &(offset, page) in pages { for &(offset, page) in pages {
let fingerprint = NeighborFingerprint::of(page, self.paper_theme); let fingerprint = NeighborFingerprint::of(page, self.paper_theme, self.pattern);
let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) { let mut neighbor = match old.iter().position(|n| n.fingerprint == fingerprint) {
Some(i) => old.swap_remove(i), Some(i) => old.swap_remove(i),
None => self.upload_neighbor(page, fingerprint), None => self.upload_neighbor(page, fingerprint),
@@ -882,11 +933,16 @@ impl CanvasState {
buffer buffer
}) })
}; };
let background = // Papier und Muster in einem Puffer (Nachbarseiten können anders groß sein)
generate_background_mesh(page.background, self.paper_theme.pattern_color()); let mut background = generate_paper_quad(self.paper_theme.paper_color(), page.size);
background.extend(generate_background_mesh(
page.background,
self.pattern.color(self.paper_theme),
page.size,
));
NeighborPage { NeighborPage {
fingerprint, fingerprint,
offset: 0, offset: glam::Vec2::ZERO,
camera_buffer, camera_buffer,
camera_bind_group, camera_bind_group,
background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background), background_buffer: vertex_buffer_with("Neighbor Background Buffer", &background),
@@ -903,14 +959,18 @@ impl CanvasState {
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource, load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
) { ) {
// Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen) // Text schärfer rastern, sobald weiter hineingezoomt wird (und gröber beim Herauszoomen)
let resolution = text_resolution( let resolution = text_resolution(self.viewport.scale * self.camera.zoom);
self.viewport.page_width / crate::document::PAGE_WIDTH * self.camera.zoom,
);
if resolution != self.text_resolution { if resolution != self.text_resolution {
self.text_resolution = resolution; self.text_resolution = resolution;
self.image_layer self.image_layer
.forget_textures(|key| key.starts_with(TEXT_KEY_PREFIX)); .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; let specs = &self.text_specs;
self.image_layer self.image_layer
.prepare(&self.device, &self.queue, &mut |key| match specs.get(key) { .prepare(&self.device, &self.queue, &mut |key| match specs.get(key) {
@@ -941,7 +1001,7 @@ impl CanvasState {
let mut quads: Vec<_> = page let mut quads: Vec<_> = page
.pdf_background .pdf_background
.iter() .iter()
.map(|pdf| (pdf.key(), pdf.corners())) .map(|pdf| (pdf.key(), pdf.corners(page.size)))
.collect(); .collect();
for stroke in &page.strokes { for stroke in &page.strokes {
let Some(corners) = stroke.object_corners() else { let Some(corners) = stroke.object_corners() else {
@@ -1187,8 +1247,6 @@ impl CanvasState {
for (i, neighbor) in self.neighbors.iter().enumerate() { for (i, neighbor) in self.neighbors.iter().enumerate() {
render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]); render_pass.set_bind_group(0, &neighbor.camera_bind_group, &[]);
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.draw(0..6, 0..1);
if let Some(buffer) = &neighbor.background_buffer { if let Some(buffer) = &neighbor.background_buffer {
render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.set_vertex_buffer(0, buffer.slice(..));
render_pass.draw(0..neighbor.background_vertex_count, 0..1); render_pass.draw(0..neighbor.background_vertex_count, 0..1);
@@ -1309,12 +1367,12 @@ fn text_key(text: &crate::text::TextBox, color: [f32; 4]) -> String {
} }
/// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit: /// Auflösung der Texttexturen für `screen_px_per_unit` Bildschirmpixel je Seiteneinheit:
/// auf Viertel-Zweierpotenzen aufgerundet (⅛ bis 4). So wird nicht bei jedem Zoomschritt neu /// 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 /// gerastert, und die Textur ist höchstens ein Fünftel größer als dargestellt – stärker
/// verkleinert wirkt die dünne Schrift blass. /// verkleinert wirkt die dünne Schrift blass.
fn text_resolution(screen_px_per_unit: f32) -> f32 { fn text_resolution(screen_px_per_unit: f32) -> f32 {
let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil(); let steps = (screen_px_per_unit.max(1e-3).log2() * 4.0).ceil();
2.0_f32.powf(steps / 4.0).clamp(0.125, 4.0) 2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
} }
#[cfg(test)] #[cfg(test)]
@@ -1328,7 +1386,7 @@ mod tests {
assert!(r >= px - 1e-4 && r < px * 1.2 + 1e-4, "{px} → {r}"); 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(0.27), text_resolution(0.28));
assert_eq!(text_resolution(100.0), 4.0); assert_eq!(text_resolution(100.0), 8.0);
} }
#[test] #[test]
+238 -65
View File
@@ -15,6 +15,8 @@ pub const A4_ASPECT_RATIO: f32 = A4_WIDTH_MM / A4_HEIGHT_MM;
/// Virtuelle Seitenauflösung in internen Einheiten (10 Einheiten = 1 mm). /// Virtuelle Seitenauflösung in internen Einheiten (10 Einheiten = 1 mm).
pub const PAGE_WIDTH: f32 = 2100.0; pub const PAGE_WIDTH: f32 = 2100.0;
pub const PAGE_HEIGHT: f32 = 2970.0; pub const PAGE_HEIGHT: f32 = 2970.0;
/// Größe einer DIN-A4-Hochformatseite (Standard) in Seiteneinheiten.
pub const A4_SIZE: glam::Vec2 = glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT);
pub const UNITS_PER_MM: f32 = 10.0; pub const UNITS_PER_MM: f32 = 10.0;
/// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten. /// Ein gefilterter Stylus-Abtastpunkt in DIN-A4-Seitenkoordinaten.
@@ -156,21 +158,32 @@ impl PdfBackground {
format!("{}#{}", self.asset, self.page) format!("{}#{}", self.asset, self.page)
} }
/// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf dem /// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf einem
/// A4-Blatt, siehe [`pdf_placement`]. /// Blatt der Größe `page_size`, siehe [`pdf_placement`].
pub fn corners(&self) -> [glam::Vec2; 4] { pub fn corners(&self, page_size: glam::Vec2) -> [glam::Vec2; 4] {
pdf_placement(self.width_pt, self.height_pt) pdf_placement(self.width_pt, self.height_pt, page_size)
} }
} }
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf dem A4-Blatt: so groß wie möglich, /// Seitengröße für eine importierte PDF-Seite von `width_pt` × `height_pt`: Hochformat wird
/// waagerecht zentriert und oben bündig. Querformat-Folien liegen so oben auf der Seite, /// DIN A4 hoch (wie bisher), Querformat (z. B. Folien) eine Seite mit der Breite von A4 quer
/// darunter bleibt Platz für Notizen. /// (297 mm) und dem Seitenverhältnis der PDF-Seite – ohne leeren Streifen darunter.
pub fn pdf_placement(width_pt: f32, height_pt: f32) -> [glam::Vec2; 4] { pub fn pdf_page_size(width_pt: f32, height_pt: f32) -> glam::Vec2 {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0)); let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h); if w <= h {
return A4_SIZE;
}
// Auf ganze Zehntelmillimeter runden, damit gleich große Folien gleich große Seiten geben
glam::Vec2::new(PAGE_HEIGHT, (PAGE_HEIGHT * h / w).round().max(1.0))
}
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf einem Blatt der Größe `page_size`:
/// so groß wie möglich, waagerecht zentriert und oben bündig.
pub fn pdf_placement(width_pt: f32, height_pt: f32, page_size: glam::Vec2) -> [glam::Vec2; 4] {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let scale = (page_size.x / w).min(page_size.y / h);
let size = glam::Vec2::new(w * scale, h * scale); let size = glam::Vec2::new(w * scale, h * scale);
let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0); let tl = glam::Vec2::new((page_size.x - size.x) / 2.0, 0.0);
[ [
tl, tl,
tl + glam::Vec2::new(size.x, 0.0), tl + glam::Vec2::new(size.x, 0.0),
@@ -184,18 +197,24 @@ pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild /// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der /// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt. /// Seitenbreite und 60 % der Seitenhöhe eines Blatts der Größe `page_size`, und möglichst
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] { /// ganz auf dem Blatt.
pub fn place_image(
width: u32,
height: u32,
center: glam::Vec2,
page_size: glam::Vec2,
) -> [glam::Vec2; 4] {
let natural = let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL; glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (PAGE_WIDTH * 0.8 / natural.x) let fit = (page_size.x * 0.8 / natural.x)
.min(PAGE_HEIGHT * 0.6 / natural.y) .min(page_size.y * 0.6 / natural.y)
.min(1.0); .min(1.0);
let size = natural * fit; let size = natural * fit;
let half = size / 2.0; let half = size / 2.0;
let center = glam::Vec2::new( let center = glam::Vec2::new(
center.x.clamp(half.x, PAGE_WIDTH - half.x), center.x.clamp(half.x, page_size.x - half.x),
center.y.clamp(half.y, PAGE_HEIGHT - half.y), center.y.clamp(half.y, page_size.y - half.y),
); );
let tl = center - half; let tl = center - half;
[ [
@@ -693,6 +712,9 @@ pub struct Page {
pub id: u64, pub id: u64,
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
pub background: PageBackground, pub background: PageBackground,
/// Seitengröße in Seiteneinheiten (Standard DIN A4 hoch, Querformat für PDF-Folien); auch im
/// Manifest, damit sie ohne Laden der Seite bekannt ist
pub size: glam::Vec2,
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte) /// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
pub pdf_background: Option<PdfBackground>, pub pdf_background: Option<PdfBackground>,
/// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist /// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist
@@ -711,6 +733,7 @@ impl Page {
id, id,
strokes: Vec::new(), strokes: Vec::new(),
background, background,
size: A4_SIZE,
pdf_background: None, pdf_background: None,
title: None, title: None,
vertex_cache: Vec::new(), vertex_cache: Vec::new(),
@@ -728,6 +751,7 @@ impl Page {
id, id,
strokes: Vec::new(), strokes: Vec::new(),
background, background,
size: A4_SIZE,
pdf_background: None, pdf_background: None,
title: None, title: None,
vertex_cache: Vec::new(), vertex_cache: Vec::new(),
@@ -739,6 +763,12 @@ impl Page {
} }
} }
/// Dieselbe Seite mit der Größe `size`.
pub fn with_size(mut self, size: glam::Vec2) -> Self {
self.size = size;
self
}
pub fn is_readonly(&self) -> bool { pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError self.persist == PersistState::ReadOnlyAfterLoadError
} }
@@ -1054,9 +1084,9 @@ impl Page {
} }
} }
/// Höhe des Bereichs am unteren Rand der letzten Seite, in dem Schreiben in der /// Anteil der Seitenhöhe am unteren Rand der letzten Seite, in dem Schreiben in der
/// durchgehenden Ansicht automatisch eine neue Seite anhängt. /// durchgehenden Ansicht automatisch eine neue Seite anhängt.
pub const AUTO_APPEND_ZONE: f32 = PAGE_HEIGHT * 0.2; pub const AUTO_APPEND_FRACTION: f32 = 0.2;
/// Das Notizbuch mit diskreter Seitenverwaltung und Navigation. /// Das Notizbuch mit diskreter Seitenverwaltung und Navigation.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -1141,20 +1171,58 @@ impl Notebook {
id id
} }
/// Fügt eine neue Seite mit dem angegebenen Hintergrund hinzu. /// Rahmen, in den jede Seite des Notizbuchs passt (größte Breite und Höhe).
pub fn fit_box(&self) -> glam::Vec2 {
self.pages
.iter()
.map(|p| p.size)
.fold(glam::Vec2::ZERO, glam::Vec2::max)
.max(glam::Vec2::ONE)
}
/// Durchgehende Ansicht: Lage aller Seiten relativ zur aktuellen.
pub fn page_stack(&self) -> crate::viewport::PageStack {
crate::viewport::PageStack::new(
self.pages.iter().map(|p| p.size).collect(),
self.current_page_index,
)
}
/// Hängt eine neue Seite mit dem angegebenen Hintergrund und der Größe der aktuellen
/// Seite an.
pub fn add_page(&mut self, background: PageBackground) -> usize { pub fn add_page(&mut self, background: PageBackground) -> usize {
let id = self.next_page_id; let id = self.next_page_id;
self.next_page_id += 1; self.next_page_id += 1;
self.pages.push(Page::new(id, background)); let size = self
.pages
.get(self.current_page_index)
.map_or(A4_SIZE, |p| p.size);
self.pages.push(Page::new(id, background).with_size(size));
self.pages.len() - 1 self.pages.len() - 1
} }
/// Fügt direkt nach der aktuellen Seite eine leere Seite mit dem angegebenen
/// Hintergrund und der Größe der aktuellen Seite ein (die aktuelle Seite bleibt aktuell)
/// und gibt ihren Index zurück.
pub fn insert_page_after_current(&mut self, background: PageBackground) -> usize {
let id = self.next_page_id;
self.next_page_id += 1;
let index = (self.current_page_index + 1).min(self.pages.len());
let size = self
.pages
.get(self.current_page_index)
.map_or(A4_SIZE, |p| p.size);
self.pages
.insert(index, Page::new(id, background).with_size(size));
index
}
/// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren /// Durchgehende Ansicht: Reicht ein neuer Strich auf der letzten Seite bis in deren
/// unteres Fünftel ([`AUTO_APPEND_ZONE`]), wird darunter eine leere Seite mit gleichem /// unteres Fünftel ([`AUTO_APPEND_FRACTION`]), wird darunter eine leere Seite mit gleichem
/// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde. /// Hintergrund angehängt. `true`, wenn eine Seite angehängt wurde.
pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool { pub fn append_page_if_writing_at_end(&mut self, stroke_bottom: f32) -> bool {
if self.current_page_index + 1 != self.pages.len() if self.current_page_index + 1 != self.pages.len()
|| stroke_bottom < PAGE_HEIGHT - AUTO_APPEND_ZONE || stroke_bottom < self.current_page().size.y * (1.0 - AUTO_APPEND_FRACTION)
{ {
return false; return false;
} }
@@ -1224,19 +1292,28 @@ impl Default for Notebook {
} }
} }
/// Berechnet die DIN-A4-Darstellung im Fenster mit Letterboxing/Pillarboxing. /// Lage der aktuellen Seite im Fenster bei Zoom 100 % ohne Verschiebung: Ein Rahmen der
/// Größe `fit_box` (alle Seiten des Notizbuchs passen hinein) wird ins Fenster eingepasst
/// (Letterboxing/Pillarboxing); die aktuelle Seite liegt darin waagerecht zentriert und oben.
/// So bleibt der Maßstab beim Wechsel zwischen verschieden großen Seiten gleich.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageViewport { pub struct PageViewport {
pub window_width: f32, pub window_width: f32,
pub window_height: f32, pub window_height: f32,
/// Bildschirmposition der linken oberen Ecke der aktuellen Seite
pub page_x: f32, pub page_x: f32,
pub page_y: f32, pub page_y: f32,
/// Größe der aktuellen Seite auf dem Bildschirm (Pixel)
pub page_width: f32, pub page_width: f32,
pub page_height: f32, pub page_height: f32,
/// Bildschirmpixel je Seiteneinheit
pub scale: f32,
/// Größe der aktuellen Seite in Seiteneinheiten
pub page_size: glam::Vec2,
} }
impl PageViewport { impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses. /// Zentrierte DIN-A4-Seite im Fenster.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self { pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0) Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
} }
@@ -1249,66 +1326,70 @@ impl PageViewport {
margin: f32, margin: f32,
inset_left: f32, inset_left: f32,
inset_right: f32, inset_right: f32,
) -> Self {
Self::calculate_for(
window_width,
window_height,
margin,
(inset_left, inset_right),
A4_SIZE,
A4_SIZE,
)
}
/// Wie [`Self::calculate_with_insets`] für eine Seite der Größe `page_size` in einem
/// Notizbuch, dessen Seiten in `fit_box` passen.
pub fn calculate_for(
window_width: f32,
window_height: f32,
margin: f32,
(inset_left, inset_right): (f32, f32),
fit_box: glam::Vec2,
page_size: glam::Vec2,
) -> Self { ) -> Self {
let inset_left = inset_left.clamp(0.0, window_width * 0.5); let inset_left = inset_left.clamp(0.0, window_width * 0.5);
let inset_right = inset_right.clamp(0.0, window_width * 0.5); let inset_right = inset_right.clamp(0.0, window_width * 0.5);
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0); let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
let avail_h = (window_height - 2.0 * margin).max(10.0); let avail_h = (window_height - 2.0 * margin).max(10.0);
let fit_box = fit_box.max(page_size).max(glam::Vec2::ONE);
let window_ratio = avail_w / avail_h; let scale = (avail_w / fit_box.x).min(avail_h / fit_box.y);
let page_ratio = A4_ASPECT_RATIO; let box_x =
inset_left + (window_width - inset_left - inset_right - fit_box.x * scale) / 2.0;
let (page_w, page_h) = if window_ratio > page_ratio { let box_y = (window_height - fit_box.y * scale) / 2.0;
// Fenster ist breiter als DIN A4 -> Pillarboxing (Balken links/rechts)
let h = avail_h;
let w = h * page_ratio;
(w, h)
} else {
// Fenster ist höher als DIN A4 -> Letterboxing (Balken oben/unten)
let w = avail_w;
let h = w / page_ratio;
(w, h)
};
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0;
Self { Self {
window_width, window_width,
window_height, window_height,
page_x, page_x: box_x + (fit_box.x - page_size.x) / 2.0 * scale,
page_y, page_y: box_y,
page_width: page_w, page_width: page_size.x * scale,
page_height: page_h, page_height: page_size.y * scale,
scale,
page_size,
} }
} }
/// Transformiert einen Fenster-Pixelpunkt in DIN-A4-Seitenkoordinaten [0.0, PAGE_WIDTH] x [0.0, PAGE_HEIGHT]. /// Transformiert einen Fenster-Pixelpunkt in Seitenkoordinaten.
/// ///
/// Liefert `None`, falls der Punkt außerhalb der DIN-A4-Papierfläche liegt. /// Liefert `None`, falls der Punkt außerhalb der Seite liegt.
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> { pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
let u = (screen_x - self.page_x) / self.page_width; let p = glam::Vec2::new(screen_x - self.page_x, screen_y - self.page_y) / self.scale;
let v = (screen_y - self.page_y) / self.page_height; ((0.0..=self.page_size.x).contains(&p.x) && (0.0..=self.page_size.y).contains(&p.y))
.then_some(p)
if (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) {
Some(glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT))
} else {
None
}
} }
/// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die DIN-A4-Papierfläche. /// Transformiert einen Fenster-Pixelpunkt und klemmt ihn auf die Seite.
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 { pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
let u = ((screen_x - self.page_x) / self.page_width).clamp(0.0, 1.0); let p = glam::Vec2::new(screen_x - self.page_x, screen_y - self.page_y) / self.scale;
let v = ((screen_y - self.page_y) / self.page_height).clamp(0.0, 1.0); p.clamp(glam::Vec2::ZERO, self.page_size)
glam::Vec2::new(u * PAGE_WIDTH, v * PAGE_HEIGHT)
} }
/// Berechnet die Shader-Transformationsparameter (Scale, Offset), /// Berechnet die Shader-Transformationsparameter (Scale, Offset),
/// um DIN-A4-Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden. /// um Seitenkoordinaten direkt im Vertex-Shader in den NDC-Bereich [-1.0, 1.0] abzubilden.
pub fn ndc_transform(&self) -> ([f32; 2], [f32; 2]) { pub fn ndc_transform(&self) -> ([f32; 2], [f32; 2]) {
let scale_x = (2.0 * self.page_width) / (self.window_width * PAGE_WIDTH); let scale_x = (2.0 * self.scale) / self.window_width;
let scale_y = (-2.0 * self.page_height) / (self.window_height * PAGE_HEIGHT); let scale_y = (-2.0 * self.scale) / self.window_height;
let offset_x = (2.0 * self.page_x) / self.window_width - 1.0; let offset_x = (2.0 * self.page_x) / self.window_width - 1.0;
let offset_y = 1.0 - (2.0 * self.page_y) / self.window_height; let offset_y = 1.0 - (2.0 * self.page_y) / self.window_height;
([scale_x, scale_y], [offset_x, offset_y]) ([scale_x, scale_y], [offset_x, offset_y])
@@ -1574,24 +1655,95 @@ mod tests {
assert!(page.strokes.is_empty()); assert!(page.strokes.is_empty());
} }
#[test]
fn test_pdf_page_size_follows_orientation() {
// Hochformat (A4, Letter) bleibt A4 hoch
assert_eq!(pdf_page_size(595.0, 842.0), A4_SIZE);
assert_eq!(pdf_page_size(612.0, 792.0), A4_SIZE);
// 16:9-Folie: 297 mm breit, Höhe nach Seitenverhältnis
let slide = pdf_page_size(960.0, 540.0);
assert_eq!(slide, glam::Vec2::new(2970.0, 1671.0));
// Die Folie füllt ihre Seite ganz aus
let [tl, _, br, _] = pdf_placement(960.0, 540.0, slide);
assert!(
tl.length() < 1e-3 && (br - slide).length() < 1.0,
"{tl} {br}"
);
}
#[test]
fn test_viewport_scale_is_shared_by_pages_of_different_size() {
let slide = glam::Vec2::new(2970.0, 1671.0);
// Nur Folien: die Folie wird ins Fenster eingepasst
let only_slides =
PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), slide, slide);
// 1560 × 860 frei: die Höhe begrenzt
assert!(
(only_slides.page_height - 860.0).abs() < 0.5,
"{only_slides:?}"
);
assert!(
(only_slides.page_width / only_slides.page_height - slide.x / slide.y).abs() < 1e-3
);
// Gemischt: gleicher Maßstab für beide Seiten, schmale Seite waagerecht zentriert
let fit = A4_SIZE.max(slide);
let a4 = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, A4_SIZE);
let wide = PageViewport::calculate_for(1600.0, 900.0, 20.0, (0.0, 0.0), fit, slide);
assert_eq!(a4.scale, wide.scale);
let center = |v: &PageViewport| v.page_x + v.page_width / 2.0;
assert!((center(&a4) - center(&wide)).abs() < 1e-3);
assert_eq!(a4.page_y, wide.page_y);
// Bildschirm → Seite berücksichtigt die Seitengröße
let p = wide
.screen_to_page(wide.page_x + wide.page_width - 1.0, wide.page_y + 1.0)
.unwrap();
assert!(p.x > 2900.0);
assert!(wide
.screen_to_page(wide.page_x + 5.0, wide.page_y + wide.page_height + 5.0)
.is_none());
}
#[test]
fn test_new_pages_take_size_of_current_page() {
let slide = glam::Vec2::new(2970.0, 1671.0);
let mut nb = Notebook::new();
nb.pages[0].size = slide;
nb.add_page(PageBackground::Blank);
nb.insert_page_after_current(PageBackground::Blank);
nb.next_page();
nb.current_page_index = nb.pages.len() - 1;
nb.next_page();
assert!(nb.pages.iter().all(|p| p.size == slide));
assert_eq!(nb.fit_box(), slide);
// Auto-Anhängen bezieht sich auf die Höhe der Folie
nb.current_page_index = nb.pages.len() - 1;
assert!(!nb.append_page_if_writing_at_end(slide.y * 0.7));
assert!(nb.append_page_if_writing_at_end(slide.y * 0.9));
}
#[test] #[test]
fn test_place_image() { fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0); let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert // Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center); let [tl, tr, br, bl] = place_image(200, 100, center, A4_SIZE);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3); assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3); assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3); assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt // Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center); let [tl, tr, br, _] = place_image(3840, 2160, center, A4_SIZE);
let size = br - tl; let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2); assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3); assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
assert_eq!(tr.y, tl.y); assert_eq!(tr.y, tl.y);
// Am Rand eingefügt: wird aufs Blatt geschoben // Am Rand eingefügt: wird aufs Blatt geschoben
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0)); let corners = place_image(
400,
400,
glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0),
A4_SIZE,
);
for c in corners { for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3); assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3); assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
@@ -1809,6 +1961,27 @@ mod tests {
)); ));
} }
#[test]
fn test_insert_page_after_current() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Lined);
nb.add_page(PageBackground::Lined);
let ids: Vec<u64> = nb.pages.iter().map(|p| p.id).collect();
nb.current_page_index = 1;
nb.pages[1].background = PageBackground::DotGrid;
let index = nb.insert_page_after_current(PageBackground::DotGrid);
assert_eq!(index, 2);
assert_eq!(nb.current_page_index, 1);
assert_eq!(nb.pages.len(), 4);
assert_eq!(nb.pages[0].id, ids[0]);
assert_eq!(nb.pages[1].id, ids[1]);
assert_eq!(nb.pages[3].id, ids[2], "alte Seite 3 ist jetzt Seite 4");
let new = &nb.pages[2];
assert!(!ids.contains(&new.id) && new.strokes.is_empty());
assert_eq!(new.background, PageBackground::DotGrid);
assert!(new.pdf_background.is_none());
}
#[test] #[test]
fn test_append_page_when_writing_at_bottom_of_last_page() { fn test_append_page_when_writing_at_bottom_of_last_page() {
let mut nb = Notebook::new(); let mut nb = Notebook::new();
+151 -82
View File
@@ -20,7 +20,7 @@ use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::assets::AssetStore; use crate::assets::AssetStore;
use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{ use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
}; };
@@ -117,26 +117,54 @@ pub enum ExportStrategy {
} }
/// Rechnet eine interne Seitenkoordinate (x, y) in PDF-Millimeter-Koordinaten um (mit Y-Inversion). /// Rechnet eine interne Seitenkoordinate (x, y) in PDF-Millimeter-Koordinaten um (mit Y-Inversion).
///
/// Bezugshöhe ist die A4-Seite; für anders hohe Seiten verschiebt [`begin_page_layer`] die
/// Ebene entsprechend.
#[inline] #[inline]
pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) { pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
let x_norm = (x / PAGE_WIDTH).clamp(0.0, 1.0); let x_mm = x / PAGE_WIDTH * A4_WIDTH_MM;
let y_norm = (y / PAGE_HEIGHT).clamp(0.0, 1.0); let y_mm = (1.0 - y / PAGE_HEIGHT) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links
let x_mm = x_norm * A4_WIDTH_MM;
let y_mm = (1.0 - y_norm) * A4_HEIGHT_MM; // PDF-Ursprung liegt unten links
(x_mm, y_mm) (x_mm, y_mm)
} }
/// PDF-Punkte je Seiteneinheit.
const PT_PER_UNIT: f32 = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4;
/// Größe der Seite `page` im PDF.
fn pdf_page_mm(page: &Page) -> (Mm, Mm) {
let mm = page.size / crate::document::UNITS_PER_MM;
(Mm(mm.x), Mm(mm.y))
}
/// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe
/// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben.
fn begin_page_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
layer.save_graphics_state();
let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT;
if dy != 0.0 {
layer.set_ctm(printpdf::CurTransMat::Translate(
printpdf::Pt(0.0),
printpdf::Pt(dy),
));
}
}
/// Beendet eine mit [`begin_page_layer`] begonnene Ebene.
fn end_page_layer(layer: &printpdf::PdfLayerReference) {
layer.restore_graphics_state();
}
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. /// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben). /// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben);
/// `pattern`: Farbe von Karos, Linien und Punkten.
pub fn export_notebook_to_pdf<P: AsRef<Path>>( pub fn export_notebook_to_pdf<P: AsRef<Path>>(
notebook: &Notebook, notebook: &Notebook,
path: P, path: P,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?; let bytes = export_notebook_to_bytes(notebook, strategy, assets, pattern)?;
let file = File::create(path)?; let file = File::create(path)?;
let mut writer = BufWriter::new(file); let mut writer = BufWriter::new(file);
std::io::Write::write_all(&mut writer, &bytes)?; std::io::Write::write_all(&mut writer, &bytes)?;
@@ -149,8 +177,9 @@ pub fn export_notebook_to_bytes(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy, assets)?; let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?; let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
// Importierte PDF-Seiten als Vektorgrafik unterlegen // Importierte PDF-Seiten als Vektorgrafik unterlegen
@@ -176,7 +205,7 @@ pub fn export_notebook_to_bytes(
); );
raster.insert(underlay.target_page); raster.insert(underlay.target_page);
} }
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?; let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?; let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let remaining: Vec<_> = underlays let remaining: Vec<_> = underlays
.into_iter() .into_iter()
@@ -240,9 +269,11 @@ fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
.enumerate() .enumerate()
.filter_map(|(i, page)| { .filter_map(|(i, page)| {
let pdf = page.pdf_background.as_ref()?; let pdf = page.pdf_background.as_ref()?;
let corners = pdf.corners(); let corners = pdf.corners(page.size);
let top_right = page_to_pdf_pt(corners[1]); // Unterlagen werden ohne die Verschiebung von `begin_page_layer` gesetzt
let bottom_left = page_to_pdf_pt(corners[3]); let shift = glam::Vec2::new(0.0, (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT);
let top_right = page_to_pdf_pt(corners[1]) + shift;
let bottom_left = page_to_pdf_pt(corners[3]) + shift;
let size = top_right - bottom_left; let size = top_right - bottom_left;
Some(crate::pdf_merge::Underlay { Some(crate::pdf_merge::Underlay {
target_page: i, target_page: i,
@@ -261,11 +292,13 @@ pub fn generate_pdf_document(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<PdfDocumentReference, Box<dyn Error>> { ) -> Result<PdfDocumentReference, Box<dyn Error>> {
generate_pdf_document_with( generate_pdf_document_with(
notebook, notebook,
strategy, strategy,
assets, assets,
pattern,
&std::collections::HashSet::new(), &std::collections::HashSet::new(),
) )
} }
@@ -276,6 +309,7 @@ fn generate_pdf_document_with(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
pattern: PatternColor,
raster_pdf_pages: &std::collections::HashSet<usize>, raster_pdf_pages: &std::collections::HashSet<usize>,
) -> Result<PdfDocumentReference, Box<dyn Error>> { ) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() { if notebook.pages.is_empty() {
@@ -292,63 +326,57 @@ fn generate_pdf_document_with(
.into()); .into());
} }
// 1. Erstes Dokument mit Seite 1 erstellen // Erste Seite legt das Dokument an, alle weiteren werden angehängt (je in ihrer Größe)
let title = "ink-notes Notebook"; let title = "ink-notes Notebook";
let (doc, page1_idx, layer1_bg) = let (width, height) = pdf_page_mm(&notebook.pages[0]);
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); let (doc, page1_idx, layer1_bg) = PdfDocument::new(title, width, height, "Background");
let fonts = embed_text_fonts(&doc, notebook)?; let fonts = embed_text_fonts(&doc, notebook)?;
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter) for (i, page) in notebook.pages.iter().enumerate() {
if let Some(title) = &notebook.pages[0].title { let (page_idx, layer_bg) = if i == 0 {
doc.add_bookmark(title.clone(), page1_idx); (page1_idx, layer1_bg)
} } else {
let (width, height) = pdf_page_mm(page);
// Seite 1 befüllen doc.add_page(width, height, "Background")
let page1_ref = doc.get_page(page1_idx); };
let layer1_bg_ref = page1_ref.get_layer(layer1_bg); // Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
if raster_pdf_pages.contains(&0) {
render_pdf_raster(&page1_ref.add_layer("PDF"), &notebook.pages[0], assets);
}
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
render_image_layer(&page1_ref.add_layer("Images"), &notebook.pages[0], assets);
}
if notebook.pages[0].strokes.iter().any(Stroke::is_text) {
render_text_layer(&page1_ref.add_layer("Text"), &notebook.pages[0], &fonts);
}
let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
// 2. Alle nachfolgenden Seiten hinzufügen
for (i, page) in notebook.pages.iter().enumerate().skip(1) {
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
if let Some(title) = &page.title { if let Some(title) = &page.title {
doc.add_bookmark(title.clone(), page_idx); doc.add_bookmark(title.clone(), page_idx);
} }
let page_ref = doc.get_page(page_idx); let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg); render_background_layer(&page_ref.get_layer(layer_bg), page, pattern);
render_background_layer(&layer_bg_ref, page.background); let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| {
let layer = page_ref.add_layer(name);
begin_page_layer(&layer, page);
render(&layer);
end_page_layer(&layer);
};
if raster_pdf_pages.contains(&i) { if raster_pdf_pages.contains(&i) {
render_pdf_raster(&page_ref.add_layer("PDF"), page, assets); layer("PDF", &|l| render_pdf_raster(l, page, assets));
} }
if page.strokes.iter().any(Stroke::is_image) { if page.strokes.iter().any(Stroke::is_image) {
render_image_layer(&page_ref.add_layer("Images"), page, assets); layer("Images", &|l| render_image_layer(l, page, assets));
} }
if page.strokes.iter().any(Stroke::is_text) { if page.strokes.iter().any(Stroke::is_text) {
render_text_layer(&page_ref.add_layer("Text"), page, &fonts); layer("Text", &|l| render_text_layer(l, page, &fonts));
} }
layer("Ink", &|l| render_ink_layer(l, page, strategy));
let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy);
} }
Ok(doc) Ok(doc)
} }
/// Rendert das Vektorraster auf Layer 1 ("Background"). /// Rendert das Vektorraster auf Layer 1 ("Background").
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) { fn render_background_layer(
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None)); layer: &printpdf::PdfLayerReference,
page: &Page,
pattern: PatternColor,
) {
let background = page.background;
let (Mm(page_width_mm), Mm(page_height_mm)) = pdf_page_mm(page);
// Exportiert wird immer auf weißem Papier
let [r, g, b, _] = pattern.color(crate::background::PaperTheme::White);
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
layer.set_outline_color(grid_color.clone()); layer.set_outline_color(grid_color.clone());
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
layer.set_fill_color(grid_color); layer.set_fill_color(grid_color);
@@ -361,11 +389,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
// Vertikale Linien alle 5 mm // Vertikale Linien alle 5 mm
let mut x = spacing_mm; let mut x = spacing_mm;
while x < A4_WIDTH_MM { while x < page_width_mm {
let line = Line { let line = Line {
points: vec![ points: vec![
(Point::new(Mm(x), Mm(0.0)), false), (Point::new(Mm(x), Mm(0.0)), false),
(Point::new(Mm(x), Mm(A4_HEIGHT_MM)), false), (Point::new(Mm(x), Mm(page_height_mm)), false),
], ],
is_closed: false, is_closed: false,
}; };
@@ -375,11 +403,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
// Horizontale Linien alle 5 mm // Horizontale Linien alle 5 mm
let mut y = spacing_mm; let mut y = spacing_mm;
while y < A4_HEIGHT_MM { while y < page_height_mm {
let line = Line { let line = Line {
points: vec![ points: vec![
(Point::new(Mm(0.0), Mm(y)), false), (Point::new(Mm(0.0), Mm(y)), false),
(Point::new(Mm(A4_WIDTH_MM), Mm(y)), false), (Point::new(Mm(page_width_mm), Mm(y)), false),
], ],
is_closed: false, is_closed: false,
}; };
@@ -394,11 +422,11 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
let margin_bottom_mm = 15.0; let margin_bottom_mm = 15.0;
let mut y = margin_bottom_mm; let mut y = margin_bottom_mm;
while y <= A4_HEIGHT_MM - margin_top_mm { while y <= page_height_mm - margin_top_mm {
let line = Line { let line = Line {
points: vec![ points: vec![
(Point::new(Mm(0.0), Mm(y)), false), (Point::new(Mm(0.0), Mm(y)), false),
(Point::new(Mm(A4_WIDTH_MM), Mm(y)), false), (Point::new(Mm(page_width_mm), Mm(y)), false),
], ],
is_closed: false, is_closed: false,
}; };
@@ -409,12 +437,12 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
PageBackground::DotGrid => { PageBackground::DotGrid => {
let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm let spacing_mm = GRID_5MM_SPACING / 10.0; // 5.0 mm
let half_dot = 0.2; // 0.4 mm Kantenlänge let half_dot = DOT_SIZE / 20.0; // Kantenlänge in mm wie am Bildschirm
let mut y = spacing_mm; let mut y = spacing_mm;
while y < A4_HEIGHT_MM { while y < page_height_mm {
let mut x = spacing_mm; let mut x = spacing_mm;
while x < A4_WIDTH_MM { while x < page_width_mm {
let dot_poly = Polygon { let dot_poly = Polygon {
rings: vec![vec![ rings: vec![vec![
(Point::new(Mm(x - half_dot), Mm(y - half_dot)), false), (Point::new(Mm(x - half_dot), Mm(y - half_dot)), false),
@@ -613,7 +641,7 @@ fn render_text_layer(
let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height()); let size = glam::Vec2::new(text.wrap_width.max(text.size), layout.height());
let (origin, [right, down]) = crate::text::frame_axes(&corners, size); let (origin, [right, down]) = crate::text::frame_axes(&corners, size);
// Seite → PDF-Punkte: x · k, Höhe − y · k // Seite → PDF-Punkte: x · k, Höhe − y · k
let k = A4_WIDTH_MM / PAGE_WIDTH * 72.0 / 25.4; let k = PT_PER_UNIT;
let o = page_to_pdf_pt(origin); let o = page_to_pdf_pt(origin);
let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y]; let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y];
layer.save_graphics_state(); layer.save_graphics_state();
@@ -730,7 +758,7 @@ fn render_pdf_raster(
placeholder_xobject() placeholder_xobject()
} }
}; };
draw_image_xobject(layer, xobject, &pdf.corners()); draw_image_xobject(layer, xobject, &pdf.corners(page.size));
} }
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
@@ -1243,8 +1271,13 @@ mod tests {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = let export_res = export_notebook_to_pdf(
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon, None); &notebook,
&pdf_path,
ExportStrategy::FilledPolygon,
None,
PatternColor::default(),
);
assert!( assert!(
export_res.is_ok(), export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein" "PDF-Export zu Datei muss erfolgreich sein"
@@ -1270,7 +1303,12 @@ mod tests {
); );
// 3. Export als Bytes testen (Strategie B: BezierCurve) // 3. Export als Bytes testen (Strategie B: BezierCurve)
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve, None); let bytes_res = export_notebook_to_bytes(
&notebook,
ExportStrategy::BezierCurve,
None,
PatternColor::default(),
);
assert!(bytes_res.is_ok()); assert!(bytes_res.is_ok());
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
@@ -1393,7 +1431,12 @@ mod tests {
); );
page.add_stroke(octahedron_stroke); page.add_stroke(octahedron_stroke);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None); let bytes_res = export_notebook_to_bytes(
&notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default(),
);
assert!(bytes_res.is_ok(), "Shape export must succeed"); assert!(bytes_res.is_ok(), "Shape export must succeed");
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
@@ -1462,9 +1505,13 @@ mod tests {
)); ));
} }
let bytes = let bytes = export_notebook_to_bytes(
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets)) &notebook,
.expect("Export mit Bildern"); ExportStrategy::FilledPolygon,
Some(&assets),
PatternColor::default(),
)
.expect("Export mit Bildern");
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!( assert!(
contains(b"/DCTDecode"), contains(b"/DCTDecode"),
@@ -1488,7 +1535,13 @@ mod tests {
assert_eq!(image_objects, 3); assert_eq!(image_objects, 3);
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen) // Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok()); assert!(export_notebook_to_bytes(
&notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default()
)
.is_ok());
} }
#[test] #[test]
@@ -1500,9 +1553,13 @@ mod tests {
crate::storage::load_all_pages(&dir, &mut notebook).unwrap(); crate::storage::load_all_pages(&dir, &mut notebook).unwrap();
let assets = AssetStore::new(&dir); let assets = AssetStore::new(&dir);
let bytes = let bytes = export_notebook_to_bytes(
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets)) &notebook,
.unwrap(); ExportStrategy::FilledPolygon,
Some(&assets),
PatternColor::default(),
)
.unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
assert_eq!(doc.get_pages().len(), 2); assert_eq!(doc.get_pages().len(), 2);
for page_id in doc.get_pages().into_values() { for page_id in doc.get_pages().into_values() {
@@ -1513,7 +1570,8 @@ mod tests {
); );
} }
// Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links // Exportiertes PDF lässt sich wieder rendern: Querformat-Folie füllt ihre Seite, rotes
// Quadrat oben links
let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap(); let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap();
let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap(); let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap();
let px = |x: u32, y: u32| { let px = |x: u32, y: u32| {
@@ -1521,10 +1579,10 @@ mod tests {
image.pixels[i..i + 3].to_vec() image.pixels[i..i + 3].to_vec()
}; };
assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links"); assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links");
assert_eq!( let ratio = image.width as f32 / image.height as f32;
px(200, 500), assert!(
vec![255, 255, 255], (ratio - 842.0 / 595.0).abs() < 0.01,
"unter der Folie ist Platz" "Seite im Folienformat: {ratio}"
); );
// Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt // Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt
@@ -1546,7 +1604,13 @@ mod tests {
assert!(!red_at(&exported, 5, 5)); assert!(!red_at(&exported, 5, 5));
// Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche) // Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok()); assert!(export_notebook_to_bytes(
&notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default()
)
.is_ok());
} }
#[test] #[test]
@@ -1557,8 +1621,13 @@ mod tests {
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect(); let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
notebook.set_page_title(ids[0], "Einleitung"); notebook.set_page_title(ids[0], "Einleitung");
notebook.set_page_title(ids[2], "Übung 2 – Integrale"); notebook.set_page_title(ids[2], "Übung 2 – Integrale");
let bytes = let bytes = export_notebook_to_bytes(
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).unwrap(); &notebook,
ExportStrategy::FilledPolygon,
None,
PatternColor::default(),
)
.unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
let catalog = doc.catalog().unwrap(); let catalog = doc.catalog().unwrap();
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen"); assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
+30 -6
View File
@@ -91,6 +91,9 @@ pub struct ImageLayer {
groups: Vec<Vec<(String, [glam::Vec2; 4])>>, groups: Vec<Vec<(String, [glam::Vec2; 4])>>,
vertex_buffer: Option<wgpu::Buffer>, vertex_buffer: Option<wgpu::Buffer>,
vertices_dirty: bool, 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 { impl ImageLayer {
@@ -170,6 +173,7 @@ impl ImageLayer {
groups: vec![Vec::new()], groups: vec![Vec::new()],
vertex_buffer: None, vertex_buffer: None,
vertices_dirty: false, vertices_dirty: false,
pdf_pixels: None,
} }
} }
@@ -194,6 +198,19 @@ impl ImageLayer {
self.groups.iter().flatten() 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; /// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
/// ohne Änderungen kostet das nichts. /// ohne Änderungen kostet das nichts.
pub fn prepare( pub fn prepare(
@@ -204,9 +221,8 @@ impl ImageLayer {
) { ) {
let missing: Vec<String> = self let missing: Vec<String> = self
.all_quads() .all_quads()
.map(|(key, _)| key) .map(|(key, _)| self.texture_key(key))
.filter(|key| !self.textures.contains_key(*key)) .filter(|key| !self.textures.contains_key(key))
.cloned()
.collect(); .collect();
for key in missing { for key in missing {
if self.textures.contains_key(&key) { if self.textures.contains_key(&key) {
@@ -230,7 +246,11 @@ impl ImageLayer {
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird // Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
if self.texture_bytes > TEXTURE_CACHE_BYTES { if self.texture_bytes > TEXTURE_CACHE_BYTES {
let used: HashSet<&String> = self.groups.iter().flatten().map(|(a, _)| a).collect(); // 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.textures.retain(|name, _| used.contains(name));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum(); self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
} }
@@ -279,7 +299,7 @@ impl ImageLayer {
/// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)? /// Fehlen noch Texturen sichtbarer Seiten (z. B. PDF-Seite im Hintergrund-Rendering)?
pub fn is_waiting(&self) -> bool { pub fn is_waiting(&self) -> bool {
self.all_quads() self.all_quads()
.any(|(key, _)| !self.textures.contains_key(key)) .any(|(key, _)| !self.textures.contains_key(&self.texture_key(key)))
} }
/// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der /// Zeichnet die Bilder einer Seite (Gruppe 0: aktuelle Seite). Bind Group 0 (Kamera der
@@ -298,7 +318,11 @@ impl ImageLayer {
render_pass.set_pipeline(&self.pipeline); render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in quads.iter().enumerate() { for (i, (asset, _)) in quads.iter().enumerate() {
let Some((bind_group, _)) = self.textures.get(asset) else { let Some((bind_group, _)) = self
.textures
.get(&self.texture_key(asset))
.or_else(|| self.textures.get(asset))
else {
continue; continue;
}; };
render_pass.set_bind_group(1, bind_group, &[]); render_pass.set_bind_group(1, bind_group, &[]);
+309 -359
View File
@@ -18,6 +18,7 @@ use winit::{
use glam::Vec2; use glam::Vec2;
use ink_notes::assets::{AssetStore, ImportedImage}; use ink_notes::assets::{AssetStore, ImportedImage};
use ink_notes::autosave::{AutosaveController, AutosaveStatus}; use ink_notes::autosave::{AutosaveController, AutosaveStatus};
use ink_notes::background::PatternColor;
use ink_notes::canvas::CanvasState; use ink_notes::canvas::CanvasState;
use ink_notes::document::{Notebook, PageBackground}; use ink_notes::document::{Notebook, PageBackground};
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
@@ -32,7 +33,6 @@ use ink_notes::settings::{SidebarSide, UiSettings};
use ink_notes::storage::{self, NotebookManifest}; use ink_notes::storage::{self, NotebookManifest};
use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
use ink_notes::updater::UpdaterController; use ink_notes::updater::UpdaterController;
use ink_notes::viewport::PAGE_PITCH;
/// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. /// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status.
/// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab. /// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab.
@@ -86,6 +86,7 @@ fn start_pdf_export(
notebook: &Notebook, notebook: &Notebook,
storage_dir: &Path, storage_dir: &Path,
title: &str, title: &str,
pattern: PatternColor,
result_tx: Sender<Result<PathBuf, String>>, result_tx: Sender<Result<PathBuf, String>>,
window: Arc<Window>, window: Arc<Window>,
) { ) {
@@ -107,6 +108,7 @@ fn start_pdf_export(
&export_path, &export_path,
ExportStrategy::FilledPolygon, ExportStrategy::FilledPolygon,
Some(&assets), Some(&assets),
pattern,
) )
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
}); });
@@ -197,10 +199,10 @@ fn switch_to_page(
return; return;
} }
if keep_view { if keep_view {
let k = index as i64 - current as i64; let origin = notebook.page_stack().origins[index];
canvas.shift_reference_page(k); canvas.shift_reference_page(origin);
if let Some(ruler) = &mut ui_state.ruler { if let Some(ruler) = &mut ui_state.ruler {
ruler.center.y -= k as f32 * PAGE_PITCH; ruler.center -= origin;
} }
canvas.ruler = ui_state.ruler; canvas.ruler = ui_state.ruler;
} }
@@ -234,11 +236,9 @@ fn page_to_focus(
if !ui_state.continuous_scroll { if !ui_state.continuous_scroll {
return None; return None;
} }
let k = canvas.page_offset_at_screen(sx, sy).filter(|&k| k != 0)?; let index = canvas
let index = usize::try_from(notebook.current_page_index as i64 + k).ok()?; .page_at_screen(&notebook.page_stack(), sx, sy)
if index >= notebook.pages.len() { .filter(|&i| i != notebook.current_page_index)?;
return None;
}
let grabs_selection = ui_state.active_tool == ActiveTool::Select let grabs_selection = ui_state.active_tool == ActiveTool::Select
&& !selection_state.is_empty() && !selection_state.is_empty()
&& !matches!( && !matches!(
@@ -364,7 +364,12 @@ fn insert_image_on_page(
ui_state: &mut UiState, ui_state: &mut UiState,
) { ) {
let id = notebook.next_stroke_id(); let id = notebook.next_stroke_id();
let corners = ink_notes::document::place_image(imported.width, imported.height, center); let corners = ink_notes::document::place_image(
imported.width,
imported.height,
center,
notebook.current_page().size,
);
let page = notebook.current_page_mut(); let page = notebook.current_page_mut();
page.add_stroke(ink_notes::document::Stroke::new_image( page.add_stroke(ink_notes::document::Stroke::new_image(
id, id,
@@ -382,6 +387,195 @@ fn insert_image_on_page(
)); ));
} }
/// Auswahlwerkzeug: Druck auf `screen` (physische Pixel) greift einen Griff, die Auswahl
/// oder das Objekt darunter oder beginnt einen Auswahlrahmen. Mit `shift` wird die Auswahl
/// erweitert. `true`, wenn etwas begonnen wurde.
fn selection_press(
screen: (f32, f32),
shift: bool,
canvas: &CanvasState,
page: &ink_notes::document::Page,
pixels_per_point: f32,
selection_state: &mut SelectionState,
) -> bool {
let (sx, sy) = screen;
let hit = selection_state.hit_test_handles(page, canvas, Vec2::new(sx, sy), pixels_per_point);
let page_pt = canvas.screen_to_page(sx, sy);
let mode = match (hit, page_pt) {
(SelectionHandleHit::RotateHandle { center_page }, Some(p)) => DragMode::Rotate {
center_page,
start_angle: (p.y - center_page.y).atan2(p.x - center_page.x),
initial_strokes: page.get_strokes(&selection_state.selected_ids),
},
(
SelectionHandleHit::CornerHandle {
fixed_corner_page,
start_pos_page,
initial_box_size,
..
},
_,
) => DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes: page.get_strokes(&selection_state.selected_ids),
},
(SelectionHandleHit::Body, Some(p)) => DragMode::Move {
start_page: p,
initial_strokes: page.get_strokes(&selection_state.selected_ids),
},
(SelectionHandleHit::None, Some(p)) => match page.find_stroke_at(p, 25.0) {
Some(stroke) => {
if shift {
selection_state.toggle_select(stroke.id);
} else if !selection_state.selected_ids.contains(&stroke.id) {
selection_state.select_single(stroke.id);
}
DragMode::Move {
start_page: p,
initial_strokes: page.get_strokes(&selection_state.selected_ids),
}
}
None => {
if !shift {
selection_state.clear();
}
DragMode::BoxSelect {
start_page: p,
current_page: p,
}
}
},
_ => return false,
};
selection_state.drag_mode = mode;
true
}
/// Auswahlwerkzeug: Ziehen nach `page_pt` – Rahmen aufziehen oder die Auswahl verschieben,
/// drehen bzw. skalieren (vorläufig, ohne Undo-Schritt).
fn selection_drag(
page_pt: Vec2,
page: &mut ink_notes::document::Page,
canvas: &mut CanvasState,
selection_state: &mut SelectionState,
) {
let mut updated = match &mut selection_state.drag_mode {
DragMode::BoxSelect { current_page, .. } => {
*current_page = page_pt;
return;
}
DragMode::None => return,
DragMode::Move {
initial_strokes, ..
}
| DragMode::Rotate {
initial_strokes, ..
}
| DragMode::Scale {
initial_strokes, ..
} => initial_strokes.clone(),
};
match &selection_state.drag_mode {
DragMode::Move { start_page, .. } => {
let delta = page_pt - *start_page;
updated.iter_mut().for_each(|s| s.translate(delta));
}
DragMode::Rotate {
center_page,
start_angle,
..
} => {
let angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x) - start_angle;
updated
.iter_mut()
.for_each(|s| s.rotate(*center_page, angle));
}
DragMode::Scale {
fixed_corner_page,
start_pos_page,
..
} => {
let orig = *start_pos_page - *fixed_corner_page;
let new = page_pt - *fixed_corner_page;
let factor = |n: f32, o: f32| {
if o.abs() > 1.0 {
(n / o).clamp(0.05, 20.0)
} else {
1.0
}
};
let factors = Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y));
updated
.iter_mut()
.for_each(|s| s.scale(*fixed_corner_page, factors));
}
DragMode::BoxSelect { .. } | DragMode::None => return,
}
page.update_strokes_ephemeral(&updated);
canvas.sync_page(page);
}
/// Auswahlwerkzeug: Loslassen – Rahmenauswahl übernehmen (mit `shift` hinzufügen) bzw.
/// Verschieben, Drehen oder Skalieren als einen Undo-Schritt abschließen. `true`, wenn ein
/// Ziehen lief.
fn selection_release(
shift: bool,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
selection_state: &mut SelectionState,
) -> bool {
match std::mem::take(&mut selection_state.drag_mode) {
DragMode::BoxSelect {
start_page,
current_page,
} => {
let box_min = start_page.min(current_page);
let box_max = start_page.max(current_page);
if (box_max - box_min).max_element() > 3.0 {
let found_ids = notebook
.current_page()
.find_strokes_in_box(box_min, box_max);
if shift {
for id in found_ids {
if !selection_state.selected_ids.contains(&id) {
selection_state.selected_ids.push(id);
}
}
} else {
selection_state.select_multiple(found_ids);
}
}
}
DragMode::Move {
initial_strokes, ..
}
| DragMode::Rotate {
initial_strokes, ..
}
| DragMode::Scale {
initial_strokes, ..
} => {
let final_strokes = notebook
.current_page()
.get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty()
&& !final_strokes.is_empty()
&& initial_strokes != final_strokes
{
notebook
.current_page_mut()
.commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_dirty(notebook, manifest);
}
}
DragMode::None => return false,
}
true
}
/// Fügt `content` als Textfeld (Schrift des Text-Werkzeugs) in der Mitte des sichtbaren /// Fügt `content` als Textfeld (Schrift des Text-Werkzeugs) in der Mitte des sichtbaren
/// Bereichs ein, wählt es aus und schaltet auf das Auswahlwerkzeug zum Verschieben. /// Bereichs ein, wählt es aus und schaltet auf das Auswahlwerkzeug zum Verschieben.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -398,6 +592,7 @@ fn insert_text_on_page(
let mut edit = ink_notes::text::TextEditing::new_at( let mut edit = ink_notes::text::TextEditing::new_at(
notebook.current_page().id, notebook.current_page().id,
Vec2::new((center.x - 600.0).max(150.0), center.y), Vec2::new((center.x - 600.0).max(150.0), center.y),
notebook.current_page().size.x,
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
ui_state.text_color, ui_state.text_color,
@@ -491,6 +686,7 @@ fn text_tool_click(
None => Some(ink_notes::text::TextEditing::new_at( None => Some(ink_notes::text::TextEditing::new_at(
page.id, page.id,
page_pt, page_pt,
page.size.x,
ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT, ui_state.text_size_pt * ink_notes::text::UNITS_PER_PT,
ui_state.text_font, ui_state.text_font,
ui_state.text_color, ui_state.text_color,
@@ -507,17 +703,19 @@ fn texture_for(
assets: &AssetStore, assets: &AssetStore,
pdf_renderer: &mut PdfRenderService, pdf_renderer: &mut PdfRenderService,
) -> TextureSource { ) -> TextureSource {
let result = match key.split_once('#') { let result = match key.contains('#') {
Some((asset, page)) => { true => {
let (Ok(path), Ok(page)) = (assets.path_of(asset), page.parse::<usize>()) else { let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
else {
return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'")); return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'"));
}; };
match pdf_renderer.take(key, path, page) { match pdf_renderer.take(key, path, page, max_pixels) {
Some(result) => result, Some(result) => result,
None => return TextureSource::Pending, None => return TextureSource::Pending,
} }
} }
None => assets false => assets
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) .load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
.map_err(|e| e.to_string()), .map_err(|e| e.to_string()),
}; };
@@ -551,6 +749,7 @@ fn start_page_export(
notebook: &Notebook, notebook: &Notebook,
storage_dir: &Path, storage_dir: &Path,
title: &str, title: &str,
pattern: PatternColor,
result_tx: Sender<Result<PathBuf, String>>, result_tx: Sender<Result<PathBuf, String>>,
window: Arc<Window>, window: Arc<Window>,
) { ) {
@@ -567,11 +766,15 @@ fn start_page_export(
std::thread::spawn(move || { std::thread::spawn(move || {
let assets = AssetStore::new(&storage_dir); let assets = AssetStore::new(&storage_dir);
let bytes = match kind { let bytes = match kind {
ui::PageExportKind::Png => page_png(&page, Some(&assets), ImageBackground::Paper), ui::PageExportKind::Png => {
ui::PageExportKind::PngTransparent => { page_png(&page, Some(&assets), ImageBackground::Paper, pattern)
page_png(&page, Some(&assets), ImageBackground::Transparent) }
ui::PageExportKind::PngTransparent => {
page_png(&page, Some(&assets), ImageBackground::Transparent, pattern)
}
ui::PageExportKind::Svg => {
page_svg(&page, Some(&assets), pattern).map(String::into_bytes)
} }
ui::PageExportKind::Svg => page_svg(&page, Some(&assets)).map(String::into_bytes),
}; };
let result = bytes let result = bytes
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -852,6 +1055,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.sidebar_side = ui_settings.sidebar_side; ui_state.sidebar_side = ui_settings.sidebar_side;
ui_state.paper_theme = ui_settings.paper_theme; ui_state.paper_theme = ui_settings.paper_theme;
ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors; ui_state.adapt_ink_colors = ui_settings.adapt_ink_colors;
ui_state.pattern_color = ui_settings.pattern_color;
ui_state.pressure_curve = ui_settings.pressure_curve; ui_state.pressure_curve = ui_settings.pressure_curve;
ui_state.continuous_scroll = ui_settings.continuous_scroll; ui_state.continuous_scroll = ui_settings.continuous_scroll;
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
@@ -889,6 +1093,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors); canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
canvas.set_pattern_color(ui_state.pattern_color);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
canvas.set_stroke_color(ui_state.pen_color); canvas.set_stroke_color(ui_state.pen_color);
canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm); canvas.set_stroke_width_mm(ui_state.pen_stroke_width_mm);
@@ -916,6 +1121,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol) // Stylus / Digitizer Zustand (Octotablet / Wayland Tablet Protocol)
let mut is_stylus_drawing = false; let mut is_stylus_drawing = false;
// Stift zieht mit dem Auswahlwerkzeug (Rahmen, Verschieben, Drehen, Skalieren)
let mut is_stylus_selecting = false;
let mut is_stylus_in_proximity = false; let mut is_stylus_in_proximity = false;
let mut stylus_tool_id: Option<octotablet::tool::ID> = None; let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
let mut stylus_barrel_pressed = false; let mut stylus_barrel_pressed = false;
@@ -1084,6 +1291,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if is_stylus_selecting {
is_stylus_selecting = false;
selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state);
window.request_redraw();
}
} }
} }
octotablet::events::ToolEvent::Button { pressed, .. } => { octotablet::events::ToolEvent::Button { pressed, .. } => {
@@ -1116,7 +1328,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen), // Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
// solange nicht auf dem Blatt gezeichnet oder radiert wird // solange nicht auf dem Blatt gezeichnet oder radiert wird
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) { if stylus_ui_capture || (!is_stylus_drawing && !is_erasing && !is_stylus_selecting) {
let ui_pos = egui::pos2(sx / scale, sy / scale); let ui_pos = egui::pos2(sx / scale, sy / scale);
egui_winit_state egui_winit_state
.egui_input_mut() .egui_input_mut()
@@ -1150,6 +1362,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
} }
} else if is_stylus_selecting {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
window.request_redraw();
}
} else if is_stylus_drawing && is_drawing { } else if is_stylus_drawing && is_drawing {
let dx = sx - stroke_hold_anchor_pos.0; let dx = sx - stroke_hold_anchor_pos.0;
let dy = sy - stroke_hold_anchor_pos.1; let dy = sy - stroke_hold_anchor_pos.1;
@@ -1203,6 +1420,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
} else if !should_erase && ui_state.active_tool == ActiveTool::Select {
if selection_press((sx, sy), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) {
is_stylus_selecting = true;
window.request_redraw();
}
} else if should_erase { } else if should_erase {
is_erasing = true; is_erasing = true;
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
@@ -1261,6 +1483,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
} }
if is_stylus_selecting {
is_stylus_selecting = false;
if selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state) {
window.request_redraw();
}
}
if is_stylus_drawing { if is_stylus_drawing {
is_stylus_drawing = false; is_stylus_drawing = false;
is_drawing = false; is_drawing = false;
@@ -1415,6 +1644,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_touch_drawing = false; is_touch_drawing = false;
is_touch_panning = false; is_touch_panning = false;
is_stylus_drawing = false; is_stylus_drawing = false;
is_stylus_selecting = false;
is_stylus_in_proximity = false; is_stylus_in_proximity = false;
} }
@@ -1605,7 +1835,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// PDF-Export: Strg + E // PDF-Export: Strg + E
Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => {
start_pdf_export(&notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); start_pdf_export(&notebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone());
ui_state.set_export_message("PDF-Export läuft…"); ui_state.set_export_message("PDF-Export läuft…");
window.request_redraw(); window.request_redraw();
} }
@@ -1858,8 +2088,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match state { match state {
ElementState::Pressed => { ElementState::Pressed => {
// Palm-Rejection: Keine Mausevents verarbeiten, wenn Stylus aktiv zeichnet // Palm-Rejection: Keine Mausevents verarbeiten, wenn der Stift aktiv
if is_stylus_drawing { // zeichnet oder auswählt (manche Systeme melden ihn zusätzlich als Maus)
if is_stylus_drawing || is_stylus_selecting {
return; return;
} }
@@ -1902,80 +2133,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 2. Objektauswahl & Transformation im Select-Modus // 2. Objektauswahl & Transformation im Select-Modus
if ui_state.active_tool == ActiveTool::Select { if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(sx, sy); if selection_press((sx, sy), modifiers.shift_key(), &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) {
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point()); window.request_redraw();
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let start_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Rotate {
center_page,
start_angle,
initial_strokes,
};
window.request_redraw();
return;
}
}
SelectionHandleHit::CornerHandle {
corner_idx: _,
fixed_corner_page,
start_pos_page,
initial_box_size,
} => {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes,
};
window.request_redraw();
return;
}
SelectionHandleHit::Body => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
return;
}
}
SelectionHandleHit::None => {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
let shift = modifiers.shift_key();
if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 25.0) {
let stroke_id = stroke.id;
if shift {
selection_state.toggle_select(stroke_id);
} else if !selection_state.selected_ids.contains(&stroke_id) {
selection_state.select_single(stroke_id);
}
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
return;
} else {
if !shift {
selection_state.clear();
}
selection_state.drag_mode = DragMode::BoxSelect {
start_page: page_pt,
current_page: page_pt,
};
window.request_redraw();
return;
}
}
}
} }
return;
} }
// 3. Tap-to-Convert im Shape-Modus // 3. Tap-to-Convert im Shape-Modus
@@ -2021,38 +2182,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if is_erasing { if is_erasing {
is_erasing = false; is_erasing = false;
} }
if ui_state.active_tool == ActiveTool::Select { if ui_state.active_tool == ActiveTool::Select
let shift = modifiers.shift_key(); && !is_stylus_selecting
match std::mem::take(&mut selection_state.drag_mode) { && selection_release(modifiers.shift_key(), &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
DragMode::BoxSelect { start_page, current_page } => { {
let box_min = Vec2::new(start_page.x.min(current_page.x), start_page.y.min(current_page.y)); window.request_redraw();
let box_max = Vec2::new(start_page.x.max(current_page.x), start_page.y.max(current_page.y));
if (box_max.x - box_min.x).max(box_max.y - box_min.y) > 3.0 {
let found_ids = notebook.current_page().find_strokes_in_box(box_min, box_max);
if shift {
for id in found_ids {
if !selection_state.selected_ids.contains(&id) {
selection_state.selected_ids.push(id);
}
}
} else {
selection_state.select_multiple(found_ids);
}
}
window.request_redraw();
}
DragMode::Move { initial_strokes, .. }
| DragMode::Rotate { initial_strokes, .. }
| DragMode::Scale { initial_strokes, .. } => {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_dirty(&notebook, &mut manifest);
}
window.request_redraw();
}
DragMode::None => {}
}
} }
if is_drawing && is_mouse_drawing { if is_drawing && is_mouse_drawing {
is_drawing = false; is_drawing = false;
@@ -2100,59 +2234,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
// Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten // Wenn der Stylus aktiv zeichnet, keine Mauscursor-Bewegungen verarbeiten
if !is_stylus_drawing { if !is_stylus_drawing && !is_stylus_selecting {
if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(sx, sy) { if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
match &mut selection_state.drag_mode { selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
DragMode::BoxSelect { current_page, .. } => { window.request_redraw();
*current_page = page_pt;
window.request_redraw();
}
DragMode::Move { start_page, initial_strokes } => {
let delta = page_pt - *start_page;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.translate(delta);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Rotate { center_page, start_angle, initial_strokes } => {
let current_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let delta_angle = current_angle - *start_angle;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.rotate(*center_page, delta_angle);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Scale { fixed_corner_page, start_pos_page, initial_box_size: _, initial_strokes } => {
let orig_delta = *start_pos_page - *fixed_corner_page;
let new_delta = page_pt - *fixed_corner_page;
let scale_x = if orig_delta.x.abs() > 1.0 {
(new_delta.x / orig_delta.x).clamp(0.05, 20.0)
} else {
1.0
};
let scale_y = if orig_delta.y.abs() > 1.0 {
(new_delta.y / orig_delta.y).clamp(0.05, 20.0)
} else {
1.0
};
let factors = Vec2::new(scale_x, scale_y);
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.scale(*fixed_corner_page, factors);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::None => {}
}
} }
} else if is_erasing { } else if is_erasing {
focus_page_at!((sx, sy)); focus_page_at!((sx, sy));
@@ -2326,69 +2412,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
} else if ui_state.active_tool == ActiveTool::Select { } else if ui_state.active_tool == ActiveTool::Select {
let screen_vec = Vec2::new(tx, ty); if selection_press((tx, ty), false, &canvas, notebook.current_page(), egui_ctx.pixels_per_point(), &mut selection_state) {
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point()); window.request_redraw();
match hit {
SelectionHandleHit::RotateHandle { center_page } => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let start_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Rotate {
center_page,
start_angle,
initial_strokes,
};
window.request_redraw();
}
}
SelectionHandleHit::CornerHandle {
corner_idx: _,
fixed_corner_page,
start_pos_page,
initial_box_size,
} => {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Scale {
fixed_corner_page,
start_pos_page,
initial_box_size,
initial_strokes,
};
window.request_redraw();
}
SelectionHandleHit::Body => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
}
}
SelectionHandleHit::None => {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
if let Some(stroke) = notebook.current_page().find_stroke_at(page_pt, 25.0) {
let stroke_id = stroke.id;
if !selection_state.selected_ids.contains(&stroke_id) {
selection_state.select_single(stroke_id);
}
let initial_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
selection_state.drag_mode = DragMode::Move {
start_page: page_pt,
initial_strokes,
};
window.request_redraw();
} else {
selection_state.clear();
selection_state.drag_mode = DragMode::BoxSelect {
start_page: page_pt,
current_page: page_pt,
};
window.request_redraw();
}
}
}
} }
} else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape { } else if ui_state.active_tool.is_drawing() || ui_state.active_tool == ActiveTool::Shape {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
@@ -2467,56 +2492,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None { } else if ui_state.active_tool == ActiveTool::Select && selection_state.drag_mode != DragMode::None {
if let Some(page_pt) = canvas.screen_to_page(tx, ty) { if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
match &mut selection_state.drag_mode { selection_drag(page_pt, notebook.current_page_mut(), &mut canvas, &mut selection_state);
DragMode::BoxSelect { current_page, .. } => { window.request_redraw();
*current_page = page_pt;
window.request_redraw();
}
DragMode::Move { start_page, initial_strokes } => {
let delta = page_pt - *start_page;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.translate(delta);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Rotate { center_page, start_angle, initial_strokes } => {
let current_angle = (page_pt.y - center_page.y).atan2(page_pt.x - center_page.x);
let delta_angle = current_angle - *start_angle;
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.rotate(*center_page, delta_angle);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::Scale { fixed_corner_page, start_pos_page, initial_box_size: _, initial_strokes } => {
let orig_delta = *start_pos_page - *fixed_corner_page;
let new_delta = page_pt - *fixed_corner_page;
let scale_x = if orig_delta.x.abs() > 1.0 {
(new_delta.x / orig_delta.x).clamp(0.05, 20.0)
} else {
1.0
};
let scale_y = if orig_delta.y.abs() > 1.0 {
(new_delta.y / orig_delta.y).clamp(0.05, 20.0)
} else {
1.0
};
let factors = Vec2::new(scale_x, scale_y);
let mut updated = initial_strokes.clone();
for s in &mut updated {
s.scale(*fixed_corner_page, factors);
}
notebook.current_page_mut().update_strokes_ephemeral(&updated);
canvas.sync_page(notebook.current_page_mut());
window.request_redraw();
}
DragMode::None => {}
}
} }
} }
} }
@@ -2534,29 +2511,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
is_erasing = false; is_erasing = false;
} }
if ui_state.active_tool == ActiveTool::Select { if ui_state.active_tool == ActiveTool::Select
match std::mem::take(&mut selection_state.drag_mode) { && selection_release(false, &mut notebook, &mut manifest, &mut autosave, &mut selection_state)
DragMode::BoxSelect { start_page, current_page } => { {
let box_min = Vec2::new(start_page.x.min(current_page.x), start_page.y.min(current_page.y)); window.request_redraw();
let box_max = Vec2::new(start_page.x.max(current_page.x), start_page.y.max(current_page.y));
if (box_max.x - box_min.x).max(box_max.y - box_min.y) > 3.0 {
let found_ids = notebook.current_page().find_strokes_in_box(box_min, box_max);
selection_state.select_multiple(found_ids);
}
window.request_redraw();
}
DragMode::Move { initial_strokes, .. }
| DragMode::Rotate { initial_strokes, .. }
| DragMode::Scale { initial_strokes, .. } => {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_dirty(&notebook, &mut manifest);
}
window.request_redraw();
}
DragMode::None => {}
}
} }
if is_touch_drawing { if is_touch_drawing {
@@ -2601,13 +2559,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// der Fenstermitte zur aktuellen (nicht während eines Strichs, einer // der Fenstermitte zur aktuellen (nicht während eines Strichs, einer
// Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite // Auswahl oder beim Ziehen des Lineals). Wer auf einer Nachbarseite
// schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie). // schreibt, bleibt dort, bis wieder gescrollt wird (Undo betrifft sie).
canvas.set_fit_box(notebook.fit_box());
if ui_state.continuous_scroll { if ui_state.continuous_scroll {
canvas.clamp_scroll(notebook.current_page_index, notebook.pages.len()); let stack = notebook.page_stack();
canvas.clamp_scroll(&stack);
// Unabhängig von der Bezugsseite: Lage bezogen auf die erste Seite
let scroll = [ let scroll = [
canvas.camera.zoom, canvas.camera.zoom,
canvas.camera.pan_offset[0], canvas.camera.pan_offset[0],
canvas.camera.pan_offset[1] canvas.camera.pan_offset[1] + stack.origins[0].y,
- notebook.current_page_index as f32 * PAGE_PITCH,
]; ];
let scrolled = last_scroll let scrolled = last_scroll
.is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3)); .is_none_or(|last: [f32; 3]| last.iter().zip(scroll).any(|(a, b)| (a - b).abs() > 1e-3));
@@ -2620,9 +2580,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if scrolled && !busy { if scrolled && !busy {
last_scroll = Some(scroll); last_scroll = Some(scroll);
let target = canvas let target = canvas
.page_offset_at_view_center() .page_at_view_center(&stack)
.filter(|&k| k != 0) .filter(|&i| i != notebook.current_page_index);
.and_then(|k| usize::try_from(notebook.current_page_index as i64 + k).ok());
if let Some(index) = target { if let Some(index) = target {
switch_to_page( switch_to_page(
index, index,
@@ -2826,15 +2785,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
manifest.pages = notebook
.pages
.iter()
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
})
.collect();
manifest.sync_from_notebook(&notebook); manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_dirty(&notebook, &mut manifest); autosave.mark_dirty(&notebook, &mut manifest);
@@ -2842,7 +2792,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
UiAction::ExportPage(kind) => { UiAction::ExportPage(kind) => {
start_page_export(kind, &notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); start_page_export(kind, &notebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone());
ui_state.set_export_message("Seite wird exportiert …".to_string()); ui_state.set_export_message("Seite wird exportiert …".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -2856,26 +2806,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
UiAction::AddPage => { UiAction::AddPage => {
selection_state.clear(); // Leere Seite direkt nach der aktuellen (in PDFs: nach der Folie,
// auf der man gerade ist) und dorthin wechseln
let bg = notebook.current_page().background; let bg = notebook.current_page().background;
notebook.add_page(bg); let index = notebook.insert_page_after_current(bg);
manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id;
manifest.pages = notebook
.pages
.iter()
.map(|p| storage::PageMeta {
id: p.id,
background: Some(p.background),
title: p.title.clone(),
})
.collect();
canvas.sync_page(notebook.current_page_mut());
manifest.sync_from_notebook(&notebook); manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); switch_to_page(
autosave.mark_dirty(&notebook, &mut manifest); index,
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); false,
window.request_redraw(); &mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&storage_dir,
&mut selection_state,
&mut ui_state,
&window,
);
ui_state.set_export_message(format!("Seite {} eingefügt", index + 1));
} }
UiAction::LoadAllPages => { UiAction::LoadAllPages => {
// Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim // Für die Vorschaubilder der Seitenauswahl; Fehler werden wie beim
@@ -2910,7 +2858,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
window.request_redraw(); window.request_redraw();
} }
UiAction::ExportPdf => { UiAction::ExportPdf => {
start_pdf_export(&notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); start_pdf_export(&notebook, &storage_dir, &manifest.title, ui_state.pattern_color, export_tx.clone(), window.clone());
ui_state.set_export_message("PDF-Export läuft…"); ui_state.set_export_message("PDF-Export läuft…");
window.request_redraw(); window.request_redraw();
} }
@@ -3246,6 +3194,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw(); window.request_redraw();
} }
UiAction::SetPatternColor(pattern) => {
canvas.set_pattern_color(pattern);
ui_settings.pattern_color = pattern;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
window.request_redraw();
}
UiAction::SetContinuousScroll(on) => { UiAction::SetContinuousScroll(on) => {
ui_settings.continuous_scroll = on; ui_settings.continuous_scroll = on;
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
@@ -3307,19 +3261,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern // 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
// Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen // Durchgehende Ansicht: sichtbare Nachbarseiten laden und mitzeichnen
if ui_state.continuous_scroll { if ui_state.continuous_scroll {
let current = notebook.current_page_index; let stack = notebook.page_stack();
let visible: Vec<(i64, usize)> = canvas let visible = canvas.visible_neighbors(&stack);
.visible_neighbor_offsets(current, notebook.pages.len()) for &index in &visible {
.into_iter()
.map(|k| (k, (current as i64 + k) as usize))
.collect();
for &(_, index) in &visible {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, index);
notebook.pages[index].rebuild_cache_if_dirty(); notebook.pages[index].rebuild_cache_if_dirty();
} }
let neighbors: Vec<(i64, &ink_notes::document::Page)> = visible let neighbors: Vec<(Vec2, &ink_notes::document::Page)> = visible
.iter() .iter()
.map(|&(k, index)| (k, &notebook.pages[index])) .map(|&index| (stack.origins[index], &notebook.pages[index]))
.collect(); .collect();
canvas.set_neighbor_pages(&neighbors); canvas.set_neighbor_pages(&neighbors);
} else { } else {
+105 -14
View File
@@ -12,6 +12,7 @@ use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
use hayro::{PixmapSettings, RenderCache, RenderSettings}; use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage}; use crate::assets::{AssetStore, RgbaImage};
use crate::background::PatternColor;
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH}; use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::export::{export_notebook_to_bytes, ExportStrategy}; use crate::export::{export_notebook_to_bytes, ExportStrategy};
@@ -30,9 +31,13 @@ pub enum ImageBackground {
} }
/// Erzeugt ein einseitiges PDF aus `page`. /// Erzeugt ein einseitiges PDF aus `page`.
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> { fn single_page_pdf(
page: &Page,
assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> {
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1); let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, assets) export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, assets, pattern)
} }
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen). /// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
@@ -91,11 +96,14 @@ pub fn page_png(
page: &Page, page: &Page,
assets: Option<&AssetStore>, assets: Option<&AssetStore>,
background: ImageBackground, background: ImageBackground,
pattern: PatternColor,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> Result<Vec<u8>, Box<dyn Error>> {
let image = match background { let image = match background {
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?, ImageBackground::Paper => {
rasterize(single_page_pdf(page, assets, pattern)?, PAGE_PNG_DPI, false)?
}
ImageBackground::Transparent => rasterize( ImageBackground::Transparent => rasterize(
single_page_pdf(&content_only(page), assets)?, single_page_pdf(&content_only(page), assets, pattern)?,
PAGE_PNG_DPI, PAGE_PNG_DPI,
true, true,
)?, )?,
@@ -104,9 +112,13 @@ pub fn page_png(
} }
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden). /// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> { pub fn page_svg(
let pdf = page: &Page,
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?; assets: Option<&AssetStore>,
pattern: PatternColor,
) -> Result<String, Box<dyn Error>> {
let pdf = Pdf::new(single_page_pdf(page, assets, pattern)?)
.map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?; let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
Ok(hayro_svg::convert( Ok(hayro_svg::convert(
pdf_page, pdf_page,
@@ -133,7 +145,11 @@ pub fn selection_image(
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max); let 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 bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?; let full = rasterize(
single_page_pdf(&selected, assets, PatternColor::default())?,
SELECTION_PNG_DPI,
true,
)?;
// Seiteneinheiten → Pixel // Seiteneinheiten → Pixel
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH; 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 x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
@@ -190,12 +206,22 @@ mod tests {
#[test] #[test]
fn test_page_png_paper_and_transparent() { fn test_page_png_paper_and_transparent() {
let page = page_with_line(); let page = page_with_line();
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap()); let paper = decode(
&page_png(&page, None, ImageBackground::Paper, PatternColor::default()).unwrap(),
);
// A4 mit 200 dpi // A4 mit 200 dpi
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32); 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"); assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); let clear = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent"); assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
// Die Linie ist rot und deckend // Die Linie ist rot und deckend
let px_per_unit = clear.width() as f32 / PAGE_WIDTH; let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
@@ -207,9 +233,22 @@ mod tests {
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01); assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
} }
#[test]
fn test_export_uses_pattern_color() {
// Kariertes Blatt: das kräftige Muster ist im Export dunkler als das helle
let page = Page::new(1, PageBackground::Grid5mm);
let darkest = |pattern| {
let png = decode(&page_png(&page, None, ImageBackground::Paper, pattern).unwrap());
png.pixels().map(|p| p.0[1]).min().unwrap()
};
let light = darkest(PatternColor::Light);
let strong = darkest(PatternColor::Strong);
assert!(strong + 60 < light, "hell {light}, kräftig {strong}");
}
#[test] #[test]
fn test_page_svg_contains_ink() { fn test_page_svg_contains_ink() {
let svg = page_svg(&page_with_line(), None).unwrap(); let svg = page_svg(&page_with_line(), None, PatternColor::default()).unwrap();
assert!(svg.starts_with("<svg") || svg.contains("<svg")); assert!(svg.starts_with("<svg") || svg.contains("<svg"));
assert!(svg.contains("<path")); assert!(svg.contains("<path"));
assert!(svg.trim_end().ends_with("</svg>")); assert!(svg.trim_end().ends_with("</svg>"));
@@ -253,7 +292,15 @@ mod tests {
#[test] #[test]
fn test_exported_text_matches_screen_rendering() { fn test_exported_text_matches_screen_rendering() {
let (page, text) = page_with_text(); let (page, text) = page_with_text();
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let px_per_unit = png.width() as f32 / PAGE_WIDTH; let px_per_unit = png.width() as f32 / PAGE_WIDTH;
let exported = ink_bounds(png.width(), png.as_raw()); let exported = ink_bounds(png.width(), png.as_raw());
@@ -278,12 +325,28 @@ mod tests {
#[test] #[test]
fn test_exported_text_follows_rotation() { fn test_exported_text_follows_rotation() {
let (mut page, _) = page_with_text(); let (mut page, _) = page_with_text();
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let straight = ink_bounds(png.width(), png.as_raw()); let straight = ink_bounds(png.width(), png.as_raw());
let center = page.strokes[0].aabb.min; let center = page.strokes[0].aabb.min;
page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2); page.strokes[0].rotate(center, std::f32::consts::FRAC_PI_2);
let png = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap()); let png = decode(
&page_png(
&page,
None,
ImageBackground::Transparent,
PatternColor::default(),
)
.unwrap(),
);
let rotated = ink_bounds(png.width(), png.as_raw()); let rotated = ink_bounds(png.width(), png.as_raw());
// 90° im Uhrzeigersinn: Breite und Höhe tauschen, Text liegt links vom Drehpunkt // 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 (w, h) = (straight.2 - straight.0, straight.3 - straight.1);
@@ -296,6 +359,34 @@ mod tests {
assert!((rotated.2 as f32) < 300.0 * px_per_unit + 2.0); 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,
PatternColor::default(),
)
.unwrap(),
);
let ratio = png.width() as f32 / png.height() as f32;
assert!((ratio - 2970.0 / 1671.0).abs() < 0.01, "{ratio}");
let px_per_unit = png.width() as f32 / 2970.0;
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, PatternColor::default()).unwrap();
assert!(svg.contains("<path"));
}
#[test] #[test]
fn test_selection_image_is_cropped() { fn test_selection_image_is_cropped() {
let page = page_with_line(); let page = page_with_line();
+43 -9
View File
@@ -16,6 +16,19 @@ use crate::assets::RgbaImage;
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi). /// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
pub const DISPLAY_RENDER_PIXELS: u32 = 2560; 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. /// Größte Seitenzahl, die importiert wird.
pub const MAX_IMPORT_PAGES: usize = 2000; pub const MAX_IMPORT_PAGES: usize = 2000;
@@ -88,6 +101,7 @@ struct RenderRequest {
key: String, key: String,
path: PathBuf, path: PathBuf,
page: usize, page: usize,
max_pixels: u32,
} }
/// Ergebnis eines Render-Auftrags. /// Ergebnis eines Render-Auftrags.
@@ -121,7 +135,7 @@ impl PdfRenderService {
.map(|pdf| (request.path.clone(), pdf)); .map(|pdf| (request.path.clone(), pdf));
} }
let result = match &open_pdf { let result = match &open_pdf {
Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS), Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
None => Err("PDF kann nicht geöffnet werden".into()), None => Err("PDF kann nicht geöffnet werden".into()),
}; };
if res_tx.send((request.key, result)).is_err() { if res_tx.send((request.key, result)).is_err() {
@@ -150,7 +164,7 @@ impl PdfRenderService {
if self.ready.len() >= 2 { if self.ready.len() >= 2 {
self.ready.clear(); self.ready.clear();
} }
self.request(key, path, page); self.request(key, path, page, DISPLAY_RENDER_PIXELS);
} }
fn collect(&mut self) { fn collect(&mut self) {
@@ -161,7 +175,7 @@ impl PdfRenderService {
} }
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist. /// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool { fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
if !self.pending.insert(key.to_string()) { if !self.pending.insert(key.to_string()) {
return true; return true;
} }
@@ -169,6 +183,7 @@ impl PdfRenderService {
key: key.to_string(), key: key.to_string(),
path, path,
page, page,
max_pixels,
}; };
if self.requests.send(request).is_err() { if self.requests.send(request).is_err() {
self.pending.remove(key); self.pending.remove(key);
@@ -178,12 +193,19 @@ impl PdfRenderService {
} }
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`. /// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> { /// 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(); self.collect();
if let Some(result) = self.ready.remove(key) { if let Some(result) = self.ready.remove(key) {
return Some(result); return Some(result);
} }
if !self.request(key, path, page) { if !self.request(key, path, page, max_pixels) {
return Some(Err("PDF-Render-Thread beendet".into())); return Some(Err("PDF-Render-Thread beendet".into()));
} }
None None
@@ -272,13 +294,14 @@ pub(crate) mod tests {
let mut service = PdfRenderService::new(move || { let mut service = PdfRenderService::new(move || {
let _ = wake_tx.send(()); let _ = wake_tx.send(());
}); });
assert!(service.take("a#0", path.clone(), 0).is_none()); let px = DISPLAY_RENDER_PIXELS;
assert!(service.take("a#0", path.clone(), 0, px).is_none());
// Zweite Anfrage erteilt keinen weiteren Auftrag // Zweite Anfrage erteilt keinen weiteren Auftrag
assert!(service.take("a#0", path.clone(), 0).is_none()); assert!(service.take("a#0", path.clone(), 0, px).is_none());
wake_rx wake_rx
.recv_timeout(std::time::Duration::from_secs(20)) .recv_timeout(std::time::Duration::from_secs(20))
.unwrap(); .unwrap();
let image = service.take("a#0", path.clone(), 0).unwrap().unwrap(); let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
assert_eq!(image.width, DISPLAY_RENDER_PIXELS); assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
// Vorab rendern: danach sofort verfügbar // Vorab rendern: danach sofort verfügbar
@@ -286,6 +309,17 @@ pub(crate) mod tests {
wake_rx wake_rx
.recv_timeout(std::time::Duration::from_secs(20)) .recv_timeout(std::time::Duration::from_secs(20))
.unwrap(); .unwrap();
assert!(service.take("a#0", path, 0).unwrap().is_ok()); 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);
} }
} }
+6 -1
View File
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::background::PaperTheme; use crate::background::{PaperTheme, PatternColor};
use crate::storage::{self, StorageError}; use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist. /// Fensterseite, an der die Werkzeugleiste angedockt ist.
@@ -82,6 +82,8 @@ pub struct UiSettings {
pub paper_theme: PaperTheme, pub paper_theme: PaperTheme,
/// Dunkle Stiftfarben auf dunklem Papier aufhellen /// Dunkle Stiftfarben auf dunklem Papier aufhellen
pub adapt_ink_colors: bool, pub adapt_ink_colors: bool,
/// Farbe von Karos, Linien und Punkten (Anzeige und Export)
pub pattern_color: PatternColor,
/// Druckkennlinie des Stifts /// Druckkennlinie des Stifts
pub pressure_curve: PressureCurve, pub pressure_curve: PressureCurve,
/// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern) /// Seiten durchgehend untereinander anzeigen (sonst einzeln blättern)
@@ -94,6 +96,7 @@ impl Default for UiSettings {
sidebar_side: SidebarSide::default(), sidebar_side: SidebarSide::default(),
paper_theme: PaperTheme::default(), paper_theme: PaperTheme::default(),
adapt_ink_colors: true, adapt_ink_colors: true,
pattern_color: PatternColor::default(),
pressure_curve: PressureCurve::default(), pressure_curve: PressureCurve::default(),
continuous_scroll: true, continuous_scroll: true,
} }
@@ -148,6 +151,7 @@ mod tests {
sidebar_side: SidebarSide::Right, sidebar_side: SidebarSide::Right,
paper_theme: PaperTheme::Black, paper_theme: PaperTheme::Black,
adapt_ink_colors: false, adapt_ink_colors: false,
pattern_color: PatternColor::Strong,
pressure_curve: PressureCurve { pressure_curve: PressureCurve {
exponent: 1.8, exponent: 1.8,
threshold: 0.1, threshold: 0.1,
@@ -178,6 +182,7 @@ mod tests {
assert!(settings.adapt_ink_colors); assert!(settings.adapt_ink_colors);
assert_eq!(settings.pressure_curve, PressureCurve::default()); assert_eq!(settings.pressure_curve, PressureCurve::default());
assert!(settings.continuous_scroll); assert!(settings.continuous_scroll);
assert_eq!(settings.pattern_color, PatternColor::Light);
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt // Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
std::fs::write( std::fs::write(
+91 -11
View File
@@ -166,7 +166,8 @@ impl StrokeData {
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`). /// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
/// - Version 6: Seitentitel (`title` in Seite und Manifest). /// - Version 6: Seitentitel (`title` in Seite und Manifest).
/// - Version 7: Textfelder (`text` am Strich). /// - Version 7: Textfelder (`text` am Strich).
pub const CURRENT_FORMAT_VERSION: u32 = 7; /// - Version 8: Seitengröße je Seite (`size` in Seite und Manifest), Querformat für Folien.
pub const CURRENT_FORMAT_VERSION: u32 = 8;
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). /// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
pub fn legacy_format_version() -> u32 { pub fn legacy_format_version() -> u32 {
@@ -186,9 +187,27 @@ pub struct PageData {
/// Seitentitel (steht zusätzlich im Manifest) /// Seitentitel (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>, pub title: Option<String>,
/// Seitengröße in Seiteneinheiten, wenn nicht DIN A4 hoch (steht zusätzlich im Manifest)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
pub strokes: Vec<StrokeData>, pub strokes: Vec<StrokeData>,
} }
/// Gespeicherte Seitengröße: `None` für DIN A4 hoch.
fn stored_size(size: glam::Vec2) -> Option<[f32; 2]> {
(size != crate::document::A4_SIZE).then(|| size.to_array())
}
/// Seitengröße aus einer (evtl. von Hand bearbeiteten) Datei; unbrauchbare Werte ergeben A4.
pub fn page_size_from(stored: Option<[f32; 2]>) -> glam::Vec2 {
match stored {
Some([w, h]) if w.is_finite() && h.is_finite() && w >= 100.0 && h >= 100.0 => {
glam::Vec2::new(w.min(20_000.0), h.min(20_000.0))
}
_ => crate::document::A4_SIZE,
}
}
impl From<&Page> for PageData { impl From<&Page> for PageData {
fn from(page: &Page) -> Self { fn from(page: &Page) -> Self {
Self { Self {
@@ -197,6 +216,7 @@ impl From<&Page> for PageData {
background: page.background, background: page.background,
pdf_background: page.pdf_background.clone(), pdf_background: page.pdf_background.clone(),
title: page.title.clone(), title: page.title.clone(),
size: stored_size(page.size),
strokes: page.strokes.iter().map(StrokeData::from).collect(), strokes: page.strokes.iter().map(StrokeData::from).collect(),
} }
} }
@@ -205,7 +225,7 @@ impl From<&Page> for PageData {
impl PageData { impl PageData {
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um. /// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
pub fn to_page(&self) -> Page { pub fn to_page(&self) -> Page {
let mut page = Page::new(self.id, self.background); let mut page = Page::new(self.id, self.background).with_size(page_size_from(self.size));
page.pdf_background = self.pdf_background.clone(); page.pdf_background = self.pdf_background.clone();
page.title = self.title.clone(); page.title = self.title.clone();
for stroke_data in &self.strokes { for stroke_data in &self.strokes {
@@ -227,6 +247,9 @@ pub struct PageMeta {
/// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt /// Seitentitel (Lesezeichen), damit die Seitenübersicht ihn ohne Laden der Seite kennt
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>, pub title: Option<String>,
/// Seitengröße, wenn nicht DIN A4 hoch (für Lage und Maßstab ungeladener Seiten)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
} }
impl PageMeta { impl PageMeta {
@@ -235,6 +258,7 @@ impl PageMeta {
id, id,
background, background,
title: None, title: None,
size: None,
} }
} }
} }
@@ -292,6 +316,15 @@ impl NotebookManifest {
.and_then(|p| p.title.clone()) .and_then(|p| p.title.clone())
} }
pub fn size_for_page(&self, page_id: u64) -> glam::Vec2 {
page_size_from(
self.pages
.iter()
.find(|p| p.id == page_id)
.and_then(|p| p.size),
)
}
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> { pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
self.pages self.pages
.iter() .iter()
@@ -308,6 +341,7 @@ impl NotebookManifest {
id: p.id, id: p.id,
background: Some(p.background), background: Some(p.background),
title: p.title.clone(), title: p.title.clone(),
size: stored_size(p.size),
}) })
.collect(); .collect();
self.page_ids = self.pages.iter().map(|p| p.id).collect(); self.page_ids = self.pages.iter().map(|p| p.id).collect();
@@ -718,7 +752,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: q_path, quarantine_path: q_path,
}, },
); );
let mut page = Page::new(id, first_bg); let mut page = Page::new(id, first_bg).with_size(manifest.size_for_page(id));
page.persist = PersistState::ReadOnlyAfterLoadError; page.persist = PersistState::ReadOnlyAfterLoadError;
page page
} }
@@ -734,13 +768,15 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
quarantine_path: None, quarantine_path: None,
}, },
); );
let mut page = Page::new(first_id, first_bg); let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError; page.persist = PersistState::ReadOnlyAfterLoadError;
page page
} }
Err(e) => { Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
let mut page = Page::new(first_id, first_bg); let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError; page.persist = PersistState::ReadOnlyAfterLoadError;
page page
} }
@@ -752,7 +788,7 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
let bg = manifest let bg = manifest
.background_for_page(page_id) .background_for_page(page_id)
.unwrap_or(PageBackground::Grid5mm); .unwrap_or(PageBackground::Grid5mm);
pages.push(Page::new_unloaded(page_id, bg)); pages.push(Page::new_unloaded(page_id, bg).with_size(manifest.size_for_page(page_id)));
} }
// Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht) // Titel aus dem Manifest (Platzhalter und Ersatzseiten kennen sie sonst nicht)
@@ -808,7 +844,7 @@ pub fn ensure_page_loaded(
// Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler // Angehängt, aber nie gespeichert (noch leer): leere Seite statt Ladefehler
Err(StorageError::PageNotFound(_)) => { Err(StorageError::PageNotFound(_)) => {
let placeholder = &mut notebook.pages[page_idx]; let placeholder = &mut notebook.pages[page_idx];
let mut page = Page::new(page_id, placeholder.background); let mut page = Page::new(page_id, placeholder.background).with_size(placeholder.size);
page.title = placeholder.title.take(); page.title = placeholder.title.take();
*placeholder = page; *placeholder = page;
Ok(()) Ok(())
@@ -1132,7 +1168,8 @@ pub fn create_notebook_from_pdf(
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, &(width_pt, height_pt))| { .map(|(i, &(width_pt, height_pt))| {
let mut page = Page::new(i as u64 + 1, PageBackground::Blank); let mut page = Page::new(i as u64 + 1, PageBackground::Blank)
.with_size(crate::document::pdf_page_size(width_pt, height_pt));
page.pdf_background = Some(crate::document::PdfBackground { page.pdf_background = Some(crate::document::PdfBackground {
asset: asset.clone(), asset: asset.clone(),
page: i as u32, page: i as u32,
@@ -1358,7 +1395,9 @@ pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<()
*page = match load_page(notebook_dir, page.id) { *page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(), Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite // Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background), Err(StorageError::PageNotFound(_)) => {
Page::new(page.id, page.background).with_size(page.size)
}
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
page.title = page.title.take().or(title); page.title = page.title.take().or(title);
@@ -2299,12 +2338,24 @@ mod tests {
let (mut loaded, _) = load_notebook(&dir).unwrap(); let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded); assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen // Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_err()); assert!(crate::export::generate_pdf_document(
&loaded,
Default::default(),
None,
Default::default()
)
.is_err());
load_all_pages(&dir, &mut loaded).unwrap(); load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1); assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined); assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default(), None).is_ok()); assert!(crate::export::generate_pdf_document(
&loaded,
Default::default(),
None,
Default::default()
)
.is_ok());
} }
#[test] #[test]
@@ -2525,6 +2576,35 @@ mod tests {
assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty()); assert!(stroke.is_text() && !stroke.is_image() && stroke.vertices.is_empty());
} }
#[test]
fn test_page_size_round_trips_and_is_known_before_loading() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Folien").unwrap();
let slide = glam::Vec2::new(2970.0, 1671.0);
notebook.pages[0].size = slide;
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
notebook.pages[2].size = crate::document::A4_SIZE;
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut reloaded, _) = load_notebook(&dir).unwrap();
let sizes: Vec<glam::Vec2> = reloaded.pages.iter().map(|p| p.size).collect();
assert_eq!(sizes, vec![slide, slide, crate::document::A4_SIZE]);
// Ungeladene Platzhalter kennen ihre Größe aus dem Manifest
assert!(!reloaded.pages[1].is_loaded);
ensure_page_loaded(&dir, &mut reloaded, 1).unwrap();
assert_eq!(reloaded.pages[1].size, slide);
// A4-Seiten speichern keine Größe (Dateien bleiben wie bisher)
let data = load_page(&dir, reloaded.pages[2].id).unwrap();
assert_eq!(data.size, None);
// Unsinnige Werte aus einer bearbeiteten Datei ergeben A4
assert_eq!(
page_size_from(Some([f32::NAN, 5.0])),
crate::document::A4_SIZE
);
}
#[test] #[test]
fn test_create_notebook_from_pdf() { fn test_create_notebook_from_pdf() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
+8 -3
View File
@@ -14,7 +14,7 @@ use ab_glyph::{Font, FontRef, GlyphId, PxScale};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::assets::RgbaImage; use crate::assets::RgbaImage;
use crate::document::{Stroke, PAGE_WIDTH, UNITS_PER_MM}; use crate::document::{Stroke, UNITS_PER_MM};
/// Schriftart eines Textfelds. /// Schriftart eines Textfelds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
@@ -270,15 +270,16 @@ pub struct TextEditing {
impl TextEditing { impl TextEditing {
/// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten /// Neues, leeres Feld: die erste Zeile mittig auf `pos`, umbrochen 1 cm vor dem rechten
/// Blattrand (mindestens 3 cm breit). /// Rand eines Blatts der Breite `page_width` (mindestens 3 cm breit).
pub fn new_at( pub fn new_at(
page_id: u64, page_id: u64,
pos: glam::Vec2, pos: glam::Vec2,
page_width: f32,
size: f32, size: f32,
font: TextFont, font: TextFont,
color: [f32; 4], color: [f32; 4],
) -> Self { ) -> Self {
let right = PAGE_WIDTH - RIGHT_MARGIN; let right = page_width - RIGHT_MARGIN;
let x = pos.x.min(right - MIN_WRAP_WIDTH).max(0.0); 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 text = TextBox::new(String::new(), size, font, (right - x).max(MIN_WRAP_WIDTH));
let line_height = text.layout().line_height; let line_height = text.layout().line_height;
@@ -400,6 +401,7 @@ pub fn rasterize(text: &TextBox, color: [f32; 4], px_per_unit: f32) -> RgbaImage
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::document::PAGE_WIDTH;
fn sample(content: &str, wrap_width: f32) -> TextBox { fn sample(content: &str, wrap_width: f32) -> TextBox {
TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width) TextBox::new(content.to_string(), 50.0, TextFont::Sans, wrap_width)
@@ -483,6 +485,7 @@ mod tests {
let edit = TextEditing::new_at( let edit = TextEditing::new_at(
1, 1,
glam::Vec2::new(500.0, 800.0), glam::Vec2::new(500.0, 800.0),
PAGE_WIDTH,
50.0, 50.0,
TextFont::Sans, TextFont::Sans,
[0.0; 4], [0.0; 4],
@@ -496,6 +499,7 @@ mod tests {
let edge = TextEditing::new_at( let edge = TextEditing::new_at(
1, 1,
glam::Vec2::new(2050.0, 100.0), glam::Vec2::new(2050.0, 100.0),
PAGE_WIDTH,
50.0, 50.0,
TextFont::Sans, TextFont::Sans,
[0.0; 4], [0.0; 4],
@@ -509,6 +513,7 @@ mod tests {
let mut edit = TextEditing::new_at( let mut edit = TextEditing::new_at(
1, 1,
glam::Vec2::new(400.0, 400.0), glam::Vec2::new(400.0, 400.0),
PAGE_WIDTH,
50.0, 50.0,
TextFont::Sans, TextFont::Sans,
[0.0; 4], [0.0; 4],
+90 -10
View File
@@ -12,7 +12,7 @@
use std::time::Instant; use std::time::Instant;
use crate::autosave::AutosaveStatus; use crate::autosave::AutosaveStatus;
use crate::background::{adapt_ink_color, PaperTheme}; use crate::background::{adapt_ink_color, PaperTheme, PatternColor};
use crate::document::{ use crate::document::{
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH, EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
}; };
@@ -198,6 +198,8 @@ pub enum UiAction {
SavePressureCurve(PressureCurve), SavePressureCurve(PressureCurve),
/// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert) /// Seiten durchgehend untereinander statt einzeln anzeigen (wird gespeichert)
SetContinuousScroll(bool), SetContinuousScroll(bool),
/// Farbe von Karos, Linien und Punkten (wird gespeichert, gilt auch für den Export)
SetPatternColor(PatternColor),
/// Bilddatei auf der aktuellen Seite einfügen /// Bilddatei auf der aktuellen Seite einfügen
InsertImageFile(std::path::PathBuf), InsertImageFile(std::path::PathBuf),
/// PDF als neues Notizbuch importieren /// PDF als neues Notizbuch importieren
@@ -266,6 +268,8 @@ pub struct UiState {
pub paper_theme: PaperTheme, pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen /// Dunkle Tinte auf dunklem Papier aufhellen
pub adapt_ink_colors: bool, pub adapt_ink_colors: bool,
/// Farbe von Karos, Linien und Punkten
pub pattern_color: PatternColor,
/// Seiten hängen untereinander und werden durchgehend gescrollt /// Seiten hängen untereinander und werden durchgehend gescrollt
pub continuous_scroll: bool, pub continuous_scroll: bool,
/// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder /// Text-Werkzeug: Farbe, Schriftgröße (Punkt) und Schriftart neuer Textfelder
@@ -335,6 +339,7 @@ impl Default for UiState {
eraser_mode: EraserMode::All, eraser_mode: EraserMode::All,
paper_theme: PaperTheme::White, paper_theme: PaperTheme::White,
adapt_ink_colors: true, adapt_ink_colors: true,
pattern_color: PatternColor::Light,
continuous_scroll: true, continuous_scroll: true,
text_color: PEN_COLOR_PRESETS[0].0, text_color: PEN_COLOR_PRESETS[0].0,
text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT, text_size_pt: crate::text::DEFAULT_TEXT_SIZE_PT,
@@ -929,27 +934,37 @@ const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH +
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus). /// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
const THUMBNAIL_POINT_BUDGET: usize = 1500; const THUMBNAIL_POINT_BUDGET: usize = 1500;
/// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` (A4-Format) in der /// Zeichenbefehle für die verkleinerte Vorschau einer Seite in `rect` in der Papierfarbe
/// Papierfarbe `paper`. Striche werden als Linien gezeichnet und bei vielen Punkten /// `paper`: so groß wie möglich, waagerecht zentriert und oben (Querformat-Seiten sind
/// gleichmäßig ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf. /// flacher). Striche werden als Linien gezeichnet und bei vielen Punkten gleichmäßig
/// ausgedünnt; `adapt_ink` hellt dunkle Tinte wie auf dem Blatt auf.
pub fn page_thumbnail_shapes( pub fn page_thumbnail_shapes(
page: &Page, page: &Page,
rect: egui::Rect, rect: egui::Rect,
paper: PaperTheme, paper: PaperTheme,
adapt_ink: bool, adapt_ink: bool,
) -> Vec<egui::Shape> { ) -> Vec<egui::Shape> {
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
let size = egui::vec2(page.size.x, page.size.y) * scale;
let sheet = egui::Rect::from_min_size(
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
size,
);
let mut shapes = vec![egui::Shape::rect_filled( let mut shapes = vec![egui::Shape::rect_filled(
rect, sheet,
egui::Rounding::same(3.0), egui::Rounding::same(3.0),
linear_color32(paper.paper_color()), linear_color32(paper.paper_color()),
)]; )];
let adapt = adapt_ink && paper.is_dark(); let adapt = adapt_ink && paper.is_dark();
let scale = rect.width() / PAGE_WIDTH; let to_screen = |p: glam::Vec2| sheet.min + egui::vec2(p.x * scale, p.y * scale);
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
// Importierte PDF-Seite als helle Fläche mit Rand // Importierte PDF-Seite als helle Fläche mit Rand
if let Some(pdf) = &page.pdf_background { if let Some(pdf) = &page.pdf_background {
let points: Vec<egui::Pos2> = pdf.corners().iter().map(|&c| to_screen(c)).collect(); let points: Vec<egui::Pos2> = pdf
.corners(page.size)
.iter()
.map(|&c| to_screen(c))
.collect();
shapes.push(egui::Shape::convex_polygon( shapes.push(egui::Shape::convex_polygon(
points, points,
egui::Color32::from_rgb(236, 238, 243), egui::Color32::from_rgb(236, 238, 243),
@@ -1273,6 +1288,49 @@ fn paper_theme_button(ui: &mut egui::Ui, theme: PaperTheme, selected: bool) -> e
response response
} }
/// Knopf für eine Musterfarbe: Vorschau (kleines Karo auf der aktuellen Papierfarbe) und
/// Bezeichnung.
fn pattern_color_button(
ui: &mut egui::Ui,
pattern: PatternColor,
paper: PaperTheme,
selected: bool,
) -> egui::Response {
let response = ui.add_sized(
[82.0, 28.0],
egui::SelectableLabel::new(selected, format!(" {}", pattern.label())),
);
let swatch = egui::Rect::from_center_size(
egui::pos2(response.rect.min.x + 15.0, response.rect.center().y),
egui::vec2(16.0, 16.0),
);
let painter = ui.painter();
painter.rect_filled(
swatch,
egui::Rounding::same(3.0),
linear_color32(paper.paper_color()),
);
let line = egui::Stroke::new(1.5_f32, linear_color32(pattern.color(paper)));
for t in [0.33, 0.67] {
let x = swatch.min.x + swatch.width() * t;
let y = swatch.min.y + swatch.height() * t;
painter.line_segment(
[egui::pos2(x, swatch.min.y), egui::pos2(x, swatch.max.y)],
line,
);
painter.line_segment(
[egui::pos2(swatch.min.x, y), egui::pos2(swatch.max.x, y)],
line,
);
}
painter.rect_stroke(
swatch,
egui::Rounding::same(3.0),
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(120)),
);
response.on_hover_text("Gilt für alle Seiten, auch beim Export")
}
/// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort /// Schieberegler für die Druckkurve mit Vorschau der Kennlinie. Änderungen gelten sofort
/// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll /// (`state.pressure_curve`); zurückgegeben wird die Kurve, sobald sie gespeichert werden soll
/// (Regler losgelassen oder per Tastatur geändert). /// (Regler losgelassen oder per Tastatur geändert).
@@ -2561,7 +2619,8 @@ pub fn render_ui(
} }
}); });
if ui if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite anhängen")) .add_sized([ui.available_width(), 30.0], egui::Button::new("➕ Neue Seite einfügen"))
.on_hover_text("Leere Seite direkt nach der aktuellen Seite")
.clicked() .clicked()
{ {
actions.push(UiAction::AddPage); actions.push(UiAction::AddPage);
@@ -2653,6 +2712,25 @@ pub fn render_ui(
}); });
} }
section(ui, "KAROS, LINIEN UND PUNKTE");
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0);
for pattern in PatternColor::ALL {
if pattern_color_button(
ui,
pattern,
state.paper_theme,
state.pattern_color == pattern,
)
.clicked()
&& state.pattern_color != pattern
{
state.pattern_color = pattern;
actions.push(UiAction::SetPatternColor(pattern));
}
}
});
section(ui, "ANSICHT"); section(ui, "ANSICHT");
if ui if ui
.checkbox(&mut state.continuous_scroll, "Seiten durchgehend scrollen") .checkbox(&mut state.continuous_scroll, "Seiten durchgehend scrollen")
@@ -2883,8 +2961,9 @@ pub fn render_ui(
if ui if ui
.add_sized( .add_sized(
[ui.available_width(), 36.0], [ui.available_width(), 36.0],
egui::Button::new("➕ Neue Seite anhängen"), egui::Button::new("➕ Neue Seite einfügen"),
) )
.on_hover_text("Leere Seite direkt nach der aktuellen Seite")
.clicked() .clicked()
{ {
actions.push(UiAction::AddPage); actions.push(UiAction::AddPage);
@@ -3761,6 +3840,7 @@ mod tests {
state.text_edit = Some(TextEditing::new_at( state.text_edit = Some(TextEditing::new_at(
notebook.current_page().id, notebook.current_page().id,
glam::Vec2::new(200.0, 300.0), glam::Vec2::new(200.0, 300.0),
PAGE_WIDTH,
42.0, 42.0,
TextFont::Sans, TextFont::Sans,
[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0],
+161 -68
View File
@@ -3,52 +3,98 @@
//! 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, UNITS_PER_MM}; 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). /// Abstand zwischen zwei Seiten in der durchgehenden Ansicht (8 mm).
pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM; pub const PAGE_GAP: f32 = 8.0 * UNITS_PER_MM;
/// Vertikaler Abstand gleicher Punkte zweier aufeinanderfolgender Seiten.
pub const PAGE_PITCH: f32 = PAGE_HEIGHT + PAGE_GAP;
/// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt. /// Höchstens so viele Nachbarseiten werden gleichzeitig dargestellt.
pub const MAX_NEIGHBOR_PAGES: usize = 12; pub const MAX_NEIGHBOR_PAGES: usize = 12;
/// Durchgehende Ansicht: Die Seiten hängen untereinander, Koordinaten beziehen sich auf die /// Durchgehende Ansicht: Die Seiten hängen mit [`PAGE_GAP`] Abstand untereinander und sind
/// aktuelle Seite. Liefert den Versatz (in Seiten) der Seite, auf der die Höhe `y` liegt, /// waagerecht zueinander zentriert. Lage jeder Seite (Ecke oben links) in Koordinaten der
/// oder `None` im Zwischenraum zweier Seiten. /// aktuellen Seite.
pub fn page_offset_at(y: f32) -> Option<i64> { #[derive(Debug, Clone, PartialEq)]
let k = (y / PAGE_PITCH).floor(); pub struct PageStack {
(y - k * PAGE_PITCH <= PAGE_HEIGHT).then_some(k as i64) pub origins: Vec<Vec2>,
pub sizes: Vec<Vec2>,
pub current: usize,
} }
/// Versätze der Seiten, die zwischen den Höhen `top` und `bottom` (Koordinaten der aktuellen impl PageStack {
/// Seite) sichtbar sind, begrenzt auf die Seitenindizes `0..page_count` und ohne die aktuelle /// Stapel aus den Seitengrößen; `current` ist die Bezugsseite.
/// Seite selbst. pub fn new(sizes: Vec<Vec2>, current: usize) -> Self {
pub fn visible_neighbor_offsets( let current = current.min(sizes.len().saturating_sub(1));
top: f32, let width = sizes.get(current).map_or(0.0, |s| s.x);
bottom: f32, let mut origins = vec![Vec2::ZERO; sizes.len()];
current: usize, let x = |i: usize| (width - sizes[i].x) / 2.0;
page_count: usize, for i in current + 1..sizes.len() {
) -> Vec<i64> { let y = origins[i - 1].y + sizes[i - 1].y + PAGE_GAP;
if !(top.is_finite() && bottom.is_finite()) || bottom < top { origins[i] = Vec2::new(x(i), y);
return Vec::new(); }
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,
}
} }
let first = (top / PAGE_PITCH).floor().max(-(current as f32)) as i64;
let last = (bottom / PAGE_PITCH) pub fn len(&self) -> usize {
.floor() self.sizes.len()
.min((page_count as f32) - 1.0 - current as f32) as i64; }
let mut offsets: Vec<i64> = (first..=last).filter(|&k| k != 0).collect();
// Sehr weit herausgezoomt: nur die nächstgelegenen Seiten pub fn is_empty(&self) -> bool {
if offsets.len() > MAX_NEIGHBOR_PAGES { self.sizes.is_empty()
offsets.sort_by_key(|k| k.unsigned_abs()); }
offsets.truncate(MAX_NEIGHBOR_PAGES);
offsets.sort_unstable(); /// 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)),
)
} }
offsets
} }
/// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan. /// Verwaltet den Viewport-Kamerazustand für stufenlosen Zoom und Pan.
@@ -80,10 +126,13 @@ impl ViewportState {
self.pan_offset = [0.0, 0.0]; self.pan_offset = [0.0, 0.0];
} }
/// Durchgehende Ansicht: Die Seite `k` Seiten weiter wird zur Bezugsseite, ohne dass sich /// Durchgehende Ansicht: Die Seite mit der Ecke `origin` (Koordinaten der bisherigen
/// der Bildausschnitt bewegt. /// Bezugsseite) wird zur Bezugsseite, ohne dass sich der Bildausschnitt bewegt. Waagerecht
pub fn shift_reference_page(&mut self, k: i64) { /// zentriert [`PageViewport`] die neue Seite bereits (ungezoomt); der Pan gleicht nur den
self.pan_offset[1] += k as f32 * PAGE_PITCH; /// 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` /// Durchgehende Ansicht: Verschiebt den Ausschnitt so, dass der Bildschirmpunkt `center`
@@ -92,14 +141,15 @@ impl ViewportState {
pub fn clamp_continuous_center( pub fn clamp_continuous_center(
&mut self, &mut self,
center: [f32; 2], center: [f32; 2],
current: usize, stack: &PageStack,
page_count: usize,
page_viewport: &PageViewport, page_viewport: &PageViewport,
) -> bool { ) -> bool {
if stack.is_empty() {
return false;
}
let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport); let c = self.screen_to_page_unclamped(center[0], center[1], page_viewport);
let top = -(current as f32) * PAGE_PITCH; let (min, max) = stack.bounds();
let bottom = page_count.saturating_sub(1 + current) as f32 * PAGE_PITCH + PAGE_HEIGHT; let clamped = c.clamp(min, max.max(min));
let clamped = Vec2::new(c.x.clamp(0.0, PAGE_WIDTH), c.y.clamp(top, bottom.max(top)));
if (clamped - c).length() < 1e-3 { if (clamped - c).length() < 1e-3 {
return false; return false;
} }
@@ -116,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);
@@ -138,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;
@@ -160,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;
@@ -182,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
@@ -197,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.
@@ -212,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;
@@ -222,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
@@ -260,30 +311,54 @@ 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] #[test]
fn test_continuous_layout() { fn test_continuous_layout() {
assert_eq!(page_offset_at(100.0), Some(0)); let stack = a4_stack(5, 1);
assert_eq!(page_offset_at(PAGE_HEIGHT + PAGE_GAP / 2.0), None); assert_eq!(stack.page_at(100.0), Some(1));
assert_eq!(page_offset_at(PAGE_PITCH + 5.0), Some(1)); assert_eq!(stack.page_at(A4.y + PAGE_GAP / 2.0), None);
assert_eq!(page_offset_at(-5.0), None); assert_eq!(stack.page_at(PITCH + 5.0), Some(2));
assert_eq!(page_offset_at(-PAGE_GAP - 5.0), Some(-1)); 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) // Ende von Seite 2 und Anfang von Seite 3 sichtbar (aktuell: Seite 2 von 5)
assert_eq!( assert_eq!(
visible_neighbor_offsets(PAGE_HEIGHT - 500.0, PAGE_PITCH + 500.0, 1, 5), stack.visible_neighbors(A4.y - 500.0, PITCH + 500.0),
vec![1] vec![2]
); );
// Ganz oben im Notizbuch: keine Seite vor der ersten // Ganz oben im Notizbuch: keine Seite vor der ersten
assert!(visible_neighbor_offsets(-PAGE_PITCH * 3.0, 500.0, 0, 5).is_empty()); assert!(a4_stack(5, 0)
.visible_neighbors(-PITCH * 3.0, 500.0)
.is_empty());
// Hinter der letzten Seite: keine weiteren // Hinter der letzten Seite: keine weiteren
assert_eq!( assert_eq!(
visible_neighbor_offsets(-PAGE_PITCH, PAGE_PITCH * 4.0, 3, 5), a4_stack(5, 3).visible_neighbors(-PITCH, PITCH * 4.0),
vec![-1, 1] vec![2, 4]
); );
// Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt // Weit herausgezoomt: auf die nächstgelegenen Seiten begrenzt
let many = visible_neighbor_offsets(-PAGE_PITCH * 50.0, PAGE_PITCH * 50.0, 50, 200); let many = a4_stack(200, 50).visible_neighbors(-PITCH * 50.0, PITCH * 50.0);
assert_eq!(many.len(), MAX_NEIGHBOR_PAGES); assert_eq!(many.len(), MAX_NEIGHBOR_PAGES);
assert!(many.iter().all(|k| k.unsigned_abs() <= 6)); 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] #[test]
@@ -292,33 +367,51 @@ mod tests {
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0); let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
vp.pan_by_screen_delta(0.0, -900.0, &page_vp); vp.pan_by_screen_delta(0.0, -900.0, &page_vp);
// Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite // Punkt oben auf der nächsten Seite, in Koordinaten der aktuellen Seite
let before = vp.page_to_screen(300.0, PAGE_PITCH + 200.0, &page_vp); let before = vp.page_to_screen(300.0, PITCH + 200.0, &page_vp);
vp.shift_reference_page(1); vp.shift_reference_page(a4_stack(2, 0).origins[1]);
let after = vp.page_to_screen(300.0, 200.0, &page_vp); 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); 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] #[test]
fn test_continuous_scroll_stays_on_the_notebook() { fn test_continuous_scroll_stays_on_the_notebook() {
let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0); let page_vp = PageViewport::calculate(1280.0, 850.0, 24.0);
let center = [640.0, 425.0]; let center = [640.0, 425.0];
let stack = a4_stack(3, 1);
let mut vp = ViewportState::new(); let mut vp = ViewportState::new();
// Mitte der Seite: nichts zu korrigieren // Mitte der Seite: nichts zu korrigieren
assert!(!vp.clamp_continuous_center(center, 1, 3, &page_vp)); assert!(!vp.clamp_continuous_center(center, &stack, &page_vp));
// Weit über die erste Seite hinaus nach oben gescrollt (aktuell: Seite 2 von 3) // 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); vp.pan_by_screen_delta(0.0, 5000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp)); assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp); let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!( assert!(
(c.y + PAGE_PITCH).abs() < 0.1, (c.y + PITCH).abs() < 0.1,
"Oberkante der ersten Seite: {c:?}" "Oberkante der ersten Seite: {c:?}"
); );
// Weit nach unten: Unterkante der letzten Seite // Weit nach unten: Unterkante der letzten Seite
vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp); vp.pan_by_screen_delta(-3000.0, -20000.0, &page_vp);
assert!(vp.clamp_continuous_center(center, 1, 3, &page_vp)); assert!(vp.clamp_continuous_center(center, &stack, &page_vp));
let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp); let c = vp.screen_to_page_unclamped(center[0], center[1], &page_vp);
assert!((c.y - (PAGE_PITCH + PAGE_HEIGHT)).abs() < 0.1, "{c:?}"); assert!((c.y - (PITCH + A4.y)).abs() < 0.1, "{c:?}");
assert!((c.x - PAGE_WIDTH).abs() < 0.1, "{c:?}"); assert!((c.x - A4.x).abs() < 0.1, "{c:?}");
} }
#[test] #[test]