Compare commits

..
47 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 587632b173 chore(release): bump version to 0.7.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m16s
Teilstrich-Radierer, Seiten-Export als PNG/SVG, Auswahl als Bild, Seitentitel.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:47:49 +02:00
haraldandClaude Opus 5.5 9a39276da4 chore(release): bump version to 0.5.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m17s
Bilder einfügen (Zwischenablage, Dateiauswahl, Drag & Drop), Dateiformat-Version 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:04:09 +02:00
haraldandClaude Opus 5.5 b5c1da07fa feat: Bilder einfügen (Datei, Zwischenablage, Drag & Drop)
- Bilder per ☰ → „Bild einfügen …“ (Strg+I, eingebaute Dateiauswahl),
  Strg+V aus der Systemzwischenablage oder durch Ziehen ins Fenster
- Bilder sind Objekte mit vier Ecken: verschieben, drehen, skalieren,
  kopieren, löschen und Undo über die bestehende Auswahl
- Gespeichert einmalig unter ihrer Prüfsumme in <Notizbuch>/assets
  (JPEG unverändert, sonst PNG); Dateiformat-Version 4
- GPU-Darstellung mit Mipmaps unter der Tinte; Radierer und Umfärben
  lassen Bilder unberührt, Tinte auf Bildern hat beim Antippen Vorrang
- PDF-Export: JPEG verlustfrei eingebettet, sonst bis 300 dpi
- Strg+V: die jüngste Kopie gewinnt (Systembild oder interne Objekte);
  Bilder werden beim Einfügen in ein anderes Notizbuch mitkopiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:50:54 +02:00
haraldandClaude Opus 5.5 c52e958c7f chore(release): bump version to 0.4.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m12s
Dunkelmodus & Papierfarben, Textmarker-Radierer, einstellbare Druckkurve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:43 +02:00
haraldandClaude Opus 5.5 3e2b627d6a feat: Textmarker-Radierer, Papierfarben/Dunkelmodus und Druckkurve
- Radierer: Modus „Nur Textmarker“ (gilt auch für Stiftrückseite und Seitentaste)
- Striche speichern jetzt ihr Werkzeug; bisher wurde jeder Strich als "pen"
  gespeichert. Ältere Textmarker werden an der Halbtransparenz erkannt.
- Papierfarben Weiß/Creme/Dunkelgrau/Schwarz (nur Anzeige); auf dunklem Papier
  hellt der Shader dunkle Tinte auf (HSL-Helligkeit gespiegelt)
- Einstellbare Druckkurve (weich/hart, Mindestdruck) mit Vorschau
- Einstellungen in ui.json; ☰-Menü scrollt bei kleinen Fenstern
- Test prüft den WGSL-Shader mit naga

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:04:59 +02:00
haraldandClaude Opus 5.5 616fc57666 chore(release): bump version to 0.3.7
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m48s
Ellipsen- und Pfeilerkennung, spitze Pfeilspitzen, Dateiformat-Version 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:42:24 +02:00
haraldandClaude Opus 5.5 d150fd1883 feat(geometry): Ellipsen- und Pfeilerkennung
Ellipse
- Achsen aus der Hauptkomponentenanalyse des gleichmäßig abgetasteten Strichs,
  Halbachsen aus der Ausdehnung (2.–98. Perzentil); fast runde Formen
  (Achsenverhältnis ≥ 0,85) bleiben Kreise; leicht schiefe werden gerade
- konkurriert im Formvergleich mit Kreis, Rechteck, Dreieck, Raute, Vieleck

Pfeil / Doppelpfeil
- in einem Zug gezeichnet: langer Schaft, an einem oder beiden Enden
  Widerhaken auf beiden Seiten (10°–80° schräg nach hinten); T-Enden und
  Zickzack sind keine Pfeile
- Schaft wird über fast gerade weiterlaufende Punkte verlängert (die
  Vereinfachung teilt ihn sonst kurz vor der Spitze)
- Spitzen über die gemeinsame arrow_head-Geometrie, Schaft endet an der Basis

Außerdem: Drehen/Skalieren/Verschieben, PDF-Export, Namen für die
Einrast-Meldung; Dateiformat-Version 3, damit ältere App-Versionen das
Notizbuch sauber ablehnen statt Seiten mit neuen Formen für beschädigt zu
halten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:28:17 +02:00
haraldandClaude Opus 5.5 f9ede537ed fix(geometry): spitze Pfeilspitzen beim Koordinatensystem
Die Achsenlinien liefen in voller Strichbreite bis zur Spitze und überdeckten
sie; zudem war die Spitze klein und breit (~67° Öffnung).

- Gemeinsame Pfeilspitzen-Geometrie (arrow_head) für Bildschirm und PDF:
  ~4 mm lang (dicke Linien länger, höchstens 1/5 der Achse), ~40° Öffnung
- Achsen enden an der Basis der Spitze
- PDF-Export nutzt dieselben Maße (vorher eigene, abweichende Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:14:06 +02:00
haraldandClaude Opus 5.5 9571e2121c chore(release): bump version to 0.3.6
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 8m51s
Unter WSL standardmäßig X11 statt Wayland.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:50:24 +02:00
haraldandClaude Opus 5.5 4a7627e461 fix(wsl): unter WSL standardmäßig X11 statt Wayland verwenden
Unter WSLg trennt der Wayland-Compositor die Verbindung, kurz nachdem die App
ein Bild über Vulkan übergibt ("Connection reset by peer", ExitFailure). Über
X11 (Xwayland) läuft die App stabil – deshalb funktionierte sie als root, wo
der Wayland-Socket nicht gefunden wird und winit auf X11 ausweicht.

- WSL-Erkennung (WSL_DISTRO_NAME/WSL_INTEROP, /proc/sys/kernel/osrelease)
- Unter WSL mit gesetztem DISPLAY wird die Ereignisschleife mit X11 erzeugt
- INK_NOTES_DISPLAY=x11|wayland übersteuert die Wahl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:42:21 +02:00
haraldandClaude Opus 5.5 cb11b1f828 chore(release): bump version to 0.3.5
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m34s
Kein Absturz mehr ohne nutzbare Grafikkarte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:17:49 +02:00
haraldandClaude Opus 5.5 89005f718e fix(grafik): kein Absturz ohne nutzbare Darstellungsmodi
Ohne Zugriff auf die Grafikkarte (z. B. Benutzer nicht in der Gruppe render)
lieferte EGL/Zink zunächst Darstellungsmodi, später eine leere Liste; die
nächste Neueinrichtung der Surface ließ wgpu abstürzen ("Requested present
mode Mailbox is not in the list of supported present modes: []").

- Adapter der Reihe nach prüfen (GPU, dann Software-Renderer); nur Adapter
  mit Formaten und Darstellungsmodi für das Fenster werden genommen
- Darstellungsmodus: Mailbox, sonst Fifo, sonst der erste angebotene
- Bei Größenänderung/verlorener Surface Fähigkeiten neu abfragen statt blind
  neu einzurichten; ohne Modi wird nicht eingerichtet (einmalige Meldung)
- Ohne nutzbaren Adapter verständliche Meldung (Gruppe render, /dev/dri,
  Software-Renderer, WGPU_BACKEND) statt Panic; WGPU_BACKEND wird beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:14:43 +02:00
haraldandClaude Opus 5.5 b5113cefa2 chore(release): bump version to 0.3.4
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m14s
Formerkennung: Rechtecke statt Kreise, Freiformen bleiben unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:21:00 +02:00
haraldandClaude Opus 5.5 88ea7d2d53 fix(geometry): Freiformen bleiben unverändert, Vielecke werden erkannt
- Eine erkannte Form ersetzt den Strich nur noch, wenn sie ihn gut beschreibt
  (mittlere Abweichung ≤ 4 % der Strichdiagonale, gemessen gegen den
  Linienzug der Form). Vorher wurde fast jeder geschlossene Strich umgewandelt
  (Herz → Kreis, Stern/Spirale → Oktaeder, Wolke → Dreieck).
- Vielecke verlangen echte Ecken: Die Richtungsänderung muss sich an den Ecken
  konzentrieren; glatte Ovale werden so nicht mehr zu Sechs-/Achtecken.
- Vielecke konkurrieren mit Kreis, Dreieck, Rechteck und Raute (gewichtet:
  Rechteck vor Raute/Vieleck, Vieleck vor Kreis); überzählige Punkte der
  Vereinfachung (Strichende vor dem Start, Punkte auf einer Geraden) entfallen.
- Tests: Freiformen bleiben unverändert, Fünf-/Sechs-/Achtecke, Ovale;
  jeder Einzeldetektor-Test prüft zusätzlich die vollständige Erkennung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:19:57 +02:00
haraldandClaude Opus 5.5 7345c00568 fix(geometry): Rechtecke und Quadrate werden nicht mehr als Kreis erkannt
- Kreis, Dreieck, Rechteck und Raute konkurrieren; gewählt wird die Form,
  deren Kontur am besten zu den Punkten passt (RMS-Abweichung). Vorher gewann
  der Kreis immer, weil auch Quadrate nur ~10 % Radiusschwankung haben, und
  die Dreieckserkennung griff gestreckte Rechtecke ab.
- Rechteckerkennung über das kleinste umschließende Rechteck (alle Drehwinkel,
  Ausreißer-robust), Kanten auf den Median der Punkte; verlangt gezeichnete
  Ecken und enge Passung, damit Ovale und Kritzeleien keine Rechtecke werden.
  Die alte Variante schnitt bei kleinen/zittrigen Strichen die Ecken ab.
- extract_k_corners wählte die geradesten statt der markantesten Punkte
  (Sortierung verkehrt herum); betrifft auch Dreieck und Raute.
- Rechteck wird der Raute vorgezogen, wenn es fast so gut passt.
- Oktaeder nur noch, wenn keine geschlossene Form gut passt.
- Tests mit nachgebildeten Freihandstrichen: Rechtecke in vielen Größen,
  Seitenverhältnissen und Drehungen, mit runden Ecken, schiefen Kanten und
  Überstrich; Kreise, Dreiecke; Ellipsen und Freiformen nie als Rechteck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:05:37 +02:00
haraldandClaude Opus 5.5 f76fad92be chore(release): bump version to 0.3.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m10s
Behebt Datenverlust nach Neustart auf einer späteren Seite (v0.2.3 – v0.3.2); Seitenauswahl mit Vorschaubildern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:58 +02:00
haraldandClaude Opus 5.5 2c60a03460 feat(ui): Vorschaubilder in der Seitenauswahl
- Kacheln zeigen eine verkleinerte A4-Vorschau jeder Seite (Striche als
  Linien, Textmarker halbtransparent, volle Seiten auf ~1500 Punkte
  ausgedünnt), Seitenzahl darunter, aktuelle Seite blau umrandet
- Noch nicht geladene Seiten werden bei offener Seitenauswahl nachgeladen
  (UiAction::LoadAllPages)
- Kachelgröße über PAGE_TILE (vorher feste Werte)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 b6580f197d fix(storage): Inhalt der zuletzt geöffneten Seite nach Neustart nicht überschreiben
Beim Start wurde nur die erste Seite geladen. War zuletzt eine spätere Seite
offen, war sie nach dem Neustart ein leerer, ungeladener Platzhalter: Das
Blatt erschien leer, und Zeichnen/Radieren/Hintergrund wechseln speicherte
den Platzhalter und überschrieb den bisherigen Inhalt der Seite.

- load_notebook lädt zusätzlich die aktuelle Seite
- Autosave schreibt nie ungeladene Platzhalter (Schutz ging beim Merge des
  Storage-Branches verloren)
- Regressionstests für beide Fälle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:03:08 +02:00
haraldandClaude Opus 5.5 22e35f2e77 chore(release): bump version to 0.3.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:40:44 +02:00
haraldandClaude Opus 5.5 a77e6cde90 feat(ui): einheitliche, deckende Dialoge und fingertaugliche Popover
- Dialoge (Notizbücher, Updates, Warnfenster) mit gemeinsamem deckendem
  Rahmen, ohne doppelte Titelleiste, zentriert neben der Werkzeugleiste;
  Schließen-Knopf 36 px, größere Knöpfe, Eingabefelder und Schieberegler
- Seitenauswahl als Popover neben dem Seitenknopf mit Kachelraster
  (56 × 48 px) und „Neue Seite anhängen“
- Immer nur ein Dialog oder Popover gleichzeitig offen (close_overlays);
  Seitenauswahl schließt bei Tippen daneben
- Stift-/Marker-Popover: Farbtupfer 30 px, Stärke-Vorschau 40 px hoch,
  breiterer Schieberegler, deckender Rahmen
- Auswahl-Überlagerung und Griff-Trefferprüfung rechnen mit der
  Display-Skalierung (lag bei Skalierung ≠ 1 neben den Strichen);
  Trefferradius der Griffe 18 Punkte für Finger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:51:48 +02:00
haraldandClaude Opus 5.5 40eb9a666f chore(release): bump version to 0.3.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m58s
Behebt: In v0.3.0 ließ sich auf dem Blatt nicht zeichnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:16:54 +02:00
haraldandClaude Opus 5.5 85d1ba1647 fix(ui): Zeichnen auf dem Blatt wieder möglich; Farben und Zeichen korrigiert
Regression aus v0.3.0: Die UI-Trefferprüfung (egui layer_id_at) wertete eine
bildschirmfüllende egui-Ebene der Ordnung Background als UI. Dadurch galt jeder
Punkt auf dem Blatt als Oberfläche, und Maus, Stift und Touch zeichneten nicht.
Background-Ebenen zählen jetzt nicht mehr als UI; die Prüfung liegt in ui.rs
und ist durch einen Test abgedeckt (Blattpunkte, Leiste, Skalierung 1 und 2).

Außerdem:
- 25 ungültige vormultiplizierte Farben (RGB > Alpha) auf unmultipliziert
  umgestellt: Knöpfe in Dialogen waren weiße Kästen mit unlesbarer Schrift,
  die Auswahlfüllung war deckend statt durchscheinend
- Zeichen ohne Glyphe in den egui-Schriften ersetzt (✓ ✕ ● ↑ ↓, Variantenzeichen
  U+FE0F hinter ⚠)
- Tests finden künftig ungültige Farben und nicht darstellbare Zeichen in
  ui.rs, selection.rs und main.rs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:12:56 +02:00
haraldandClaude Opus 5.5 b03faedd27 chore(release): bump version to 0.3.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:26:27 +02:00
haraldandClaude Opus 5.5 f17e50ec32 feat(ui): senkrechte Werkzeugleiste und Stift-/Touch-Bedienung der UI
Werkzeugleiste
- Schmale senkrechte Leiste statt ~30 Elemente in einer Zeile; seitlich oben
  angedockt (links, im ☰-Menü auf rechts umschaltbar, gespeichert in
  ~/.config/ink-notes/ui.json)
- Symbol-Knöpfe 40 px, auf niedrigen Fenstern schrittweise bis 28 px; Größe
  richtet sich nach dem höchsten Fall, damit Knöpfe beim Werkzeugwechsel
  nicht springen (fester Platz für die Farbpalette)
- Selten genutztes (PDF, Hintergrund, Zoom, Finger-Zeichnen, Leistenseite,
  Updates, Speicherstatus) im ☰-Menü; Popover öffnen zur Blattseite hin
- Blatt wird im freien Bereich neben der Leiste zentriert
- Symbole ohne Glyphe in den egui-Schriften (🖍 ⌫ ⬚ ◄ ► ● ✕ ▾) ersetzt bzw.
  selbst gezeichnet; Hover-Farbe korrigiert (falsch vormultipliziert)

Eingabe
- UI-Trefferprüfung in logischen Punkten (vorher physische Pixel gegen
  egui-Koordinaten: bei Skalierung ≠ 1 trafen Stift und Touch die Leiste nicht)
  und über egui::Context::layer_id_at für alle egui-Bereiche
- Stift-Ereignisse über egui_input_mut() statt ctx.input_mut() (wurden vom
  nächsten Frame verworfen); Stiftbewegungen für Hover/Ziehen weitergereicht,
  Stift bleibt vom Aufsetzen bis Abheben bei der UI
- Berührungen, die auf der UI beginnen, bleiben bis zum Abheben bei egui
- egui-Neuzeichenwünsche (repaint_delay) werden beachtet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:51 +02:00
haraldandClaude Opus 5.5 5cd45831e2 chore(release): bump version to 0.2.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 71f6a70829 fix(ci): Release-Signatur mit rsign2 statt minisign-CLI
Der Signaturschlüssel wurde mit `rsign generate -W` erzeugt und ist mit
leerem Passwort verschlüsselt; das minisign-CLI aus Ubuntu (0.11) verlangt
dafür ein Terminal und brach in der CI mit "Error while loading the secret
key file" ab.

- Workflow installiert rsign2 0.6.7 (ohne -D warnings) statt minisign
- build-release.sh signiert und verifiziert mit rsign; da rsign bei falscher
  Key-ID mit Exit-Code 0 endet, wird zusätzlich die Erfolgsmeldung geprüft
- README: Signierwerkzeug und Fallstrick dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 b751b71223 chore(release): bump version to 0.2.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 9m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:51 +02:00
haraldandClaude Opus 5.5 afb057692d fix(ci): rustfmt und clippy in der Release-Pipeline installieren
setup-rust-toolchain installiert mit --profile minimal; ohne explizite
components fehlen rustfmt und clippy, wodurch die Quality Gates bei jedem
Release-Lauf sofort abbrachen (cargo fmt --check nach 2 Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:42:58 +02:00
haraldandClaude Opus 5.5 eeae143dc6 chore(release): bump version to 0.2.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:10 +02:00
haraldandClaude Opus 5.5 992c3daad7 fix(ci): Formatierung, Root-fester I/O-Test und robuste Schlüsselsuche
- updater.rs: rustfmt-konforme Formatierung des öffentlichen Schlüssels
  (cargo fmt --check ließ die Release-Pipeline scheitern)
- storage.rs: test_io_error_* simuliert den I/O-Fehler über ein Verzeichnis
  statt chmod 000, damit er auch als root (CI-Runner) greift
- build-release.sh: findet den öffentlichen Schlüssel auch, wenn rustfmt ihn
  in die Folgezeile umbricht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:30:52 +02:00
haraldandClaude Opus 5.5 9684238a53 chore(release): bump version to 0.2.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:20:29 +02:00
haraldandClaude Opus 5.5 e8674e730f feat(updater): Minisign-Schlüssel für Release-Signaturen hinterlegen
Öffentlicher Schlüssel (Key-ID 3CFE290C190DA9D9). Der geheime Schlüssel wird
in der CI als Secret MINISIGN_SECRET_KEY erwartet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:10:57 +02:00
haraldandClaude Opus 5.5 7e99d59042 Merge fix/storage-datensicherheit in main
Konfliktlösung: Speicher-Architektur des Branches (Formatversionierung,
Papierkorb, Backups, Instance-Lock, robuster Autosave) übernommen und die
dort fehlenden Korrekturen aus main darauf übertragen:

- Autosave gleicht das Manifest bei jeder Änderung mit dem Notizbuch ab
  (mark_dirty); per Taste N/Bild-ab angelegte Seiten gingen sonst nach
  einem Neustart verloren
- Start öffnet keine beschädigten Notizbücher automatisch und verwendet den
  Ordner eines nicht ladbaren Notizbuchs nicht wieder
- PDF-Export lädt nicht geöffnete Seiten nach (load_all_pages), verweigert
  unvollständige Exporte und meldet das echte Ergebnis an die UI
- Cargo.toml: minisign-verify und fs2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:22 +02:00
haraldandClaude Opus 5.5 7ed971d2a2 feat(updater): Pflicht-Signaturprüfung der Releases mit Minisign
- SHA256SUMS.txt muss mit dem eingebauten Minisign-Schlüssel
  (UPDATE_SIGNING_PUBLIC_KEY) signiert sein; fehlende oder ungültige
  Signaturen und Versionen ohne Schlüssel lehnen Updates ab
- Prüfsummen und Signatur werden vor dem Paket geladen und geprüft
- build-release.sh signiert mit MINISIGN_SECRET_KEY und prüft, dass der
  Schlüssel zum eingebauten öffentlichen Schlüssel passt; in der CI ist die
  Signatur Pflicht
- publish-gitea-release.sh lädt SHA256SUMS.txt.minisig hoch
- README: Einrichtung des Schlüsselpaars dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:49:25 +02:00
haraldandClaude Opus 5.5 5ce2c250bb fix(updater): HTTPS für alle Anfragen erzwingen
Bisher wurde nur die Server-URL geprüft. Download-Links aus der Release-API
und Weiterleitungsziele durften auch http:// sein, womit Paket und
SHA256SUMS.txt unverschlüsselt geladen werden konnten. Jetzt wird jede
Anfrage einzeln geprüft (allow_insecure_http bleibt als Ausnahme).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:16 +02:00
haraldandClaude Opus 5.5 d61e1819ed fix(storage): Datenverlust bei Ladefehlern, neuen Seiten und Löschen verhindern
- Beschädigte Seiten werden als page_<id>.json.corrupt-<zeit> gesichert statt
  leer überschrieben; bei I/O-Fehlern wird die Ersatzseite als load_failed
  markiert und nie gespeichert
- Autosave gleicht das Manifest (page_ids, Zähler) mit dem Notizbuch ab, damit
  neu angelegte Seiten nach einem Neustart nicht verloren gehen
- Start: nicht ladbares Notizbuch bleibt unverändert, stattdessen wird ein
  neues angelegt
- Aktuelles Notizbuch löschen: ausstehende Schreibvorgänge vorher abarbeiten,
  damit der Ordner nicht wieder angelegt wird
- PDF-Export lädt nicht geöffnete Seiten nach und verweigert unvollständige
  Exporte; Ergebnis wird per Kanal an die UI gemeldet
- Strg+S speichert alle geladenen Seiten und meldet Schreibfehler; flush()
  liefert Fehler zurück statt immer "Gespeichert"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:43:16 +02:00
harald 12755d8eea test(storage): Crash-Simulation und Stresstest gegen unvollständige Schreibvorgänge 2026-10-06 19:21:59 +02:00
harald 510035e807 perf(storage): kompaktes JSON, Stroke-Treue und Snapshot-Optimierung 2026-10-06 19:14:52 +02:00
harald 1afe4a0f9d feat(storage): Dateisystem-Härtung, Trash, Rolling-Backup und Instance-Lock 2026-10-06 18:55:08 +02:00
harald 04666bf389 feat(autosave): robuster Autosave mit Max-Wait, Retries, PageStore-Trait und Shutdown-Sicherheit 2026-10-06 17:52:09 +02:00
harald a82402bfce feat(storage): Formatversionierung, Migrations-Backups und Fixture-Tests 2026-10-06 17:16:39 +02:00
harald 06b3aeb311 fix(storage): kein stilles Überschreiben bei Ladefehlern und Quarantäne defekter Seiten 2026-10-06 16:58:23 +02:00
38 changed files with 13686 additions and 1927 deletions

No files matched your search

+11 -1
View File
@@ -44,18 +44,28 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with: with:
toolchain: stable toolchain: stable
# Die Action installiert mit --profile minimal; ohne diese Komponenten
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
components: rustfmt, clippy
cache: true cache: true
- name: Install rsign2 (Release-Signatur)
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
- name: Code Quality & Testing (Quality Gates) - name: Code Quality & Testing (Quality Gates)
run: | run: |
cargo fmt --check cargo fmt --check
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
cargo test cargo test
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt) - name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
run: | run: |
chmod +x scripts/*.sh chmod +x scripts/*.sh
./scripts/build-release.sh ./scripts/build-release.sh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
INK_NOTES_REQUIRE_SIGNATURE: "1"
- name: Publish Release to Gitea - name: Publish Release to Gitea
run: | run: |
Generated
+729 -19
View File
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -1,12 +1,12 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.1.1" version = "0.7.0"
edition = "2021" edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux" description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
rust-version = "1.75" rust-version = "1.92"
[dependencies] [dependencies]
# Fenster- & Event-Handling (Wayland/X11, Stylus) # Fenster- & Event-Handling (Wayland/X11, Stylus)
@@ -40,6 +40,14 @@ ureq = { version = "2.10", default-features = false, features = ["tls", "gzip",
url = "2.5" url = "2.5"
octotablet = "0.1.0" octotablet = "0.1.0"
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
arboard = { version = "3", default-features = false, features = ["image-data"] }
minisign-verify = "0.3.0"
fs2 = "0.4"
hayro = "0.8"
hayro-svg = "0.8"
[profile.dev] [profile.dev]
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
@@ -51,3 +59,5 @@ strip = true
[dev-dependencies] [dev-dependencies]
tempfile = "3.27.0" tempfile = "3.27.0"
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
naga = { version = "0.19", features = ["wgsl-in"] }
+25 -8
View File
@@ -71,7 +71,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog). - **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
- **Portables .AppImage**: Eigenständiges x86_64 Bundle. - **Portables .AppImage**: Eigenständiges x86_64 Bundle.
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, SHA-256-Prüfung und atomarer In-Place Austausch. - **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
### 1. Release-Pakete bauen ### 1. Release-Pakete bauen
@@ -84,6 +84,23 @@ Ergebnis in `target/`:
- `ink-notes_0.1.0_amd64.deb` - `ink-notes_0.1.0_amd64.deb`
- `ink-notes_0.1.0_x86_64.AppImage` - `ink-notes_0.1.0_x86_64.AppImage`
- `SHA256SUMS.txt` - `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04 ### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
@@ -123,15 +140,15 @@ export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien: Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **SHA-256-Integritätsprüfung:** 1. **Minisign-Signatur (Pflicht):**
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen. - Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`). - Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
2. **SHA-256-Integritätsprüfung:**
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen. - Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
- *Hinweis zur Authentizität:* Eine Prüfsumme vom selben Release-Server garantiert Schutz gegen Übertragungsfehler und Datei-Beschädigungen, jedoch keine kryptografische Nicht-Abstreitbarkeit gegenüber einem kompromittierten Server.
2. **Signatur-Ausblick:**
- Die Updater-Architektur enthält Schnittstellen für optionale `.sig`/`.minisig`-Signaturen zur zukünftigen End-to-End-Kryptoverifikation über öffentliche Schlüssel.
3. **Redirect-Schutz & HTTPS:** 3. **Redirect-Schutz & HTTPS:**
- Alle API- und Download-Endpunkte erzwingen standardmäßig HTTPS. - Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist. - Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:** 4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`). - Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
+54 -51
View File
@@ -1,6 +1,6 @@
# Dateiformat & Speicherstruktur von ink-notes # Dateiformat & Speicherstruktur von ink-notes
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt. Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
--- ---
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
└── notebooks/ └── notebooks/
├── <notebook_id_1>/ ├── <notebook_id_1>/
│ ├── manifest.json │ ├── manifest.json
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
│ ├── page_1.json │ ├── page_1.json
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
│ ├── page_2.json │ ├── page_2.json
│ └── ... │ └── ...
└── <notebook_id_2>/ └── <notebook_id_2>/
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
```json ```json
{ {
"format_version": 2,
"id": "notebook-a1b2c3d4", "id": "notebook-a1b2c3d4",
"title": "Mathematik Vorlesung", "title": "Mathematik Vorlesung",
"version": 1,
"pages": [ "pages": [
{ {
"id": 1, "id": 1,
"file_name": "page_1.json" "background": "Grid5mm"
}, },
{ {
"id": 2, "id": 2,
"file_name": "page_2.json" "background": "DotGrid"
} }
], ],
"page_ids": [1, 2],
"current_page_index": 0, "current_page_index": 0,
"next_page_id": 3, "next_page_id": 3,
"next_stroke_id": 42, "next_stroke_id": 42,
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
``` ```
### Felder: ### Felder:
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`). - `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs. - `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
- `version`: Format-Versionsnummer (aktuell `1`). - `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
- `pages`: Geordnete Liste von Seitenreferenzen. - `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore. - `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten. - `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
- `next_stroke_id`: Globaler ID-Zähler für neue Striche. - `next_stroke_id`: Globaler ID-Zähler für neue Striche.
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
## 3. Seitendatei (`page_<id>.json`) ## 3. Seitendatei (`page_<id>.json`)
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen. Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
```json ```json
{ {"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
"id": 1,
"background": "Grid5mm",
"strokes": [
{
"id": 1,
"color": [0.12, 0.12, 0.14, 1.0],
"width_mm": 0.5,
"samples": [
{
"x": 350.2,
"y": 420.5,
"pressure": 0.65,
"timestamp": 12.345
},
{
"x": 352.1,
"y": 425.8,
"pressure": 0.70,
"timestamp": 12.355
}
],
"recognized_shape": null
},
{
"id": 2,
"color": [0.15, 0.35, 0.85, 1.0],
"width_mm": 0.8,
"samples": [],
"recognized_shape": {
"Rectangle": {
"top_left": { "x": 500.0, "y": 600.0 },
"width": 300.0,
"height": 200.0
}
}
}
]
}
``` ```
### Felder: ### Felder:
- `format_version`: Format-Versionsnummer (`2`).
- `id`: Eindeutige ID der Seite. - `id`: Eindeutige ID der Seite.
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`). - `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen. - `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
- `id`: Eindeutige ID des Strichs. - `id`: Eindeutige ID des Strichs.
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`. - `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
- `width_mm`: Basis-Strichbreite in Millimetern. - `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden). - `shape`: Optionales Enum für geometrische Vektorformen:
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
- `Line { start, end }` - `Line { start, end }`
- `Rectangle { top_left, width, height }` - `Rectangle { top_left, width, height }`
- `Circle { center, radius }` - `Circle { center, radius }`
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
- `Triangle { p1, p2, p3 }` - `Triangle { p1, p2, p3 }`
- `Rhombus { center, width, height }` - `Rhombus { center, width, height }`
- `Octahedron { center, radius }` - `Octahedron { center, radius }`
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
--- ---
## 4. Atomares Schreibverfahren ## 4. Datensicherheit & Migration
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern: ### Atomares Schreiben & Verzeichnis-Sync
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben. 1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt. 2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
### Quarantäne bei Parsefehlern
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
### Migrations-Backups & Rolling Backups
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
---
## 5. Versionshistorie
| Version | Status | Beschreibung |
|---|---|---|
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
---
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
- **Vorteile**:
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
- **Nachteile**:
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
- **Entscheidung**:
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
+19 -3
View File
@@ -3,12 +3,28 @@
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04). Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
### Highlights: ### Highlights:
- **Teilstrich-Radierer**: Nur den überstrichenen Teil eines Strichs entfernen – ein Radierzug ist ein Undo-Schritt
- **Export & Zwischenablage**: Aktuelle Seite als PNG (auch transparent) oder SVG; Strg+C legt die Auswahl zusätzlich als Bild in die Zwischenablage für Chat, Mail oder Office
- **Seitentitel & Inhaltsverzeichnis**: Seiten benennen, Inhaltsansicht in der Seitenübersicht, Titel werden zu PDF-Lesezeichen (Dateiformat-Version 6)
- **PDF importieren & annotieren**: Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen (☰-Menü oder Drag & Drop), darauf schreiben und markieren; beim Export liegen die Originalseiten verlustfrei als Vektor unter den Notizen (Dateiformat-Version 5: ältere Versionen öffnen solche Notizbücher nicht)
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung - **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus - **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation) - **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold) - **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave - **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases - **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe - **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
### Installation: ### Installation:
+42 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # ==============================================================================
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux) # Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/ # Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
# ============================================================================== # ==============================================================================
set -euo pipefail set -euo pipefail
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target" cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}" sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
rm -f "${SIGNATURE_FILE}"
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
if [ -z "${EMBEDDED_PUBKEY}" ]; then
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
exit 1
fi
if ! command -v rsign >/dev/null 2>&1; then
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
exit 1
fi
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
KEY_FILE="$(mktemp)"
trap 'rm -f "${KEY_FILE}"' EXIT
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
rm -f "${KEY_FILE}"
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
echo "${VERIFY_OUT}"
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
rm -f "${SIGNATURE_FILE}"
exit 1
fi
echo "${VERIFY_OUT}"
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
exit 1
else
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
echo " Installierte Apps lehnen ein solches Release als Update ab."
fi
echo "======================================================================" echo "======================================================================"
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:" echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
echo "======================================================================" echo "======================================================================"
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}" ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
echo "" echo ""
echo "Inhalt von target/${CHECKSUM_FILE}:" echo "Inhalt von target/${CHECKSUM_FILE}:"
cat "${CHECKSUM_FILE}" cat "${CHECKSUM_FILE}"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# ==============================================================================
# scripts/crash-test.sh
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
# und verifiziert die Integrität der Notizbücher und Seiten.
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ITERATIONS="${1:-200}"
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
echo "=================================================================="
echo " ink-notes Crash- & Datenverlust-Stresstest"
echo " Iterationen: ${ITERATIONS}"
echo " Testverzeichnis: ${TEST_DIR}"
echo "=================================================================="
# 1. Sicherstellen, dass das Test-Binary gebaut ist
if [ ! -f "${CRASH_SIM_BIN}" ]; then
echo "Baue crash_sim Release-Binary..."
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
fi
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
rm -rf "${TEST_DIR}"
mkdir -p "${TEST_DIR}"
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
SUCCESS_COUNT=0
INTERRUPTED_WRITES=0
trap 'rm -rf "${TEST_DIR}"' EXIT
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
for i in $(seq 1 "${ITERATIONS}"); do
# Worker im Hintergrund starten
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
WORKER_PID=$!
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
MS_DELAY=$(( (RANDOM % 33) + 2 ))
DELAY="0.$(printf "%03d" "$MS_DELAY")"
sleep "${DELAY}"
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
if kill -9 "${WORKER_PID}" 2>/dev/null; then
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
fi
wait "${WORKER_PID}" 2>/dev/null || true
# Integritäts- und Konsistenzprüfung
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
echo ""
echo "❌ FEHLER in Iteration ${i}!"
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
trap - EXIT
exit 1
fi
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
fi
done
echo ""
echo "=================================================================="
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
echo " - Keine unvollständigen Atom-Schreibungen"
echo "=================================================================="
+9 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # ==============================================================================
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu) # Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
# Lädt .deb, .AppImage und SHA256SUMS.txt idempotent als Release-Assets hoch. # Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ============================================================================== # ==============================================================================
set -euo pipefail set -euo pipefail
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb" DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage" APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt" CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl" TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
if [ ! -f "${TEMPLATE_FILE}" ]; then if [ ! -f "${TEMPLATE_FILE}" ]; then
@@ -46,6 +47,12 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
"${SCRIPT_DIR}/build-release.sh" "${SCRIPT_DIR}/build-release.sh"
fi fi
# Ohne Signatur lehnen installierte Apps das Release als Update ab
if [ ! -f "${SIGNATURE_FILE}" ]; then
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
exit 1
fi
echo "======================================================================" echo "======================================================================"
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}" echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
echo "======================================================================" echo "======================================================================"
@@ -230,6 +237,7 @@ except Exception:
upload_asset "${DEB_FILE}" upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}" upload_asset "${APPIMAGE_FILE}"
upload_asset "${CHECKSUMS_FILE}" upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
echo "======================================================================" echo "======================================================================"
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!" echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
+366
View File
@@ -0,0 +1,366 @@
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
//!
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
//! Kopien auf anderen Seiten immer funktionieren.
//!
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
//! werden als PNG gespeichert.
use std::io::Cursor;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::storage;
/// Name des Unterordners mit den Bilddateien.
pub const ASSETS_DIR: &str = "assets";
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
/// Größte Dateigröße beim Einfügen.
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
/// Fehler beim Einfügen oder Laden eines Bildes.
#[derive(Debug)]
pub enum AssetError {
/// Format nicht unterstützt oder Datei beschädigt
Decode(String),
/// Datei zu groß
TooLarge(usize),
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
InvalidName(String),
Io(std::io::Error),
}
impl std::fmt::Display for AssetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
AssetError::TooLarge(bytes) => write!(
f,
"Bild ist zu groß ({} MB, höchstens {} MB)",
bytes / (1024 * 1024),
MAX_IMPORT_BYTES / (1024 * 1024)
),
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
}
}
}
impl std::error::Error for AssetError {}
impl From<std::io::Error> for AssetError {
fn from(e: std::io::Error) -> Self {
AssetError::Io(e)
}
}
/// Ergebnis eines erfolgreichen Imports.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedImage {
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
pub asset: String,
pub width: u32,
pub height: u32,
}
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
#[derive(Debug, Clone, PartialEq)]
pub struct RgbaImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png`, `.jpg` oder `.pdf`. Verhindert, dass
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
pub fn is_valid_asset_name(name: &str) -> bool {
let Some((hash, ext)) = name.split_once('.') else {
return false;
};
hash.len() == 64
&& hash
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
&& matches!(ext, "png" | "jpg" | "pdf")
}
/// Bilddateien eines Notizbuchs.
#[derive(Debug, Clone)]
pub struct AssetStore {
dir: PathBuf,
}
impl AssetStore {
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
pub fn new(notebook_dir: &Path) -> Self {
Self {
dir: notebook_dir.join(ASSETS_DIR),
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Pfad einer Bilddatei (nur für gültige Namen).
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
if is_valid_asset_name(asset) {
Ok(self.dir.join(asset))
} else {
Err(AssetError::InvalidName(asset.to_string()))
}
}
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
if bytes.len() > MAX_IMPORT_BYTES {
return Err(AssetError::TooLarge(bytes.len()));
}
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
let decoded = image::load_from_memory_with_format(bytes, format)
.map_err(|e| AssetError::Decode(e.to_string()))?;
let fits =
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
if format == image::ImageFormat::Jpeg && fits {
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
return self.store(bytes, "jpg", decoded.width(), decoded.height());
}
let decoded = if fits {
decoded
} else {
decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
)
};
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Übernimmt eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein).
pub fn import_pdf(&self, bytes: &[u8]) -> Result<String, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.pdf", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(asset)
}
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
pub fn import_rgba(
&self,
width: u32,
height: u32,
pixels: Vec<u8>,
) -> Result<ImportedImage, AssetError> {
let buffer = image::RgbaImage::from_raw(width, height, pixels)
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
decoded = decoded.resize(
MAX_IMPORT_DIMENSION,
MAX_IMPORT_DIMENSION,
image::imageops::FilterType::Triangle,
);
}
let png = encode_png(&decoded)?;
self.store(&png, "png", decoded.width(), decoded.height())
}
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
let target = self.path_of(asset)?;
if target.exists() || other.dir == self.dir {
return Ok(());
}
let bytes = std::fs::read(other.path_of(asset)?)?;
storage::atomic_write_file(&target, &bytes)?;
Ok(())
}
/// Liest die gespeicherte Bilddatei unverändert.
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
Ok(std::fs::read(self.path_of(asset)?)?)
}
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
/// `max_dimension` Pixel Kantenlänge.
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
let bytes = self.read_bytes(asset)?;
let mut decoded =
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
if decoded.width() > max_dimension || decoded.height() > max_dimension {
decoded = decoded.resize(
max_dimension,
max_dimension,
image::imageops::FilterType::Triangle,
);
}
let rgba = decoded.into_rgba8();
Ok(RgbaImage {
width: rgba.width(),
height: rgba.height(),
pixels: rgba.into_raw(),
})
}
fn store(
&self,
bytes: &[u8],
ext: &str,
width: u32,
height: u32,
) -> Result<ImportedImage, AssetError> {
let hash = Sha256::digest(bytes);
let asset = format!("{}.{ext}", hex(&hash));
let path = self.path_of(&asset)?;
if !path.exists() {
storage::atomic_write_file(&path, bytes)?;
}
Ok(ImportedImage {
asset,
width,
height,
})
}
}
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
let mut png = Vec::new();
image
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| AssetError::Decode(e.to_string()))?;
Ok(png)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
pub fn is_supported_image_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
)
})
.unwrap_or(false)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
pub(crate) fn sample_image_bytes(
width: u32,
height: u32,
format: image::ImageFormat,
) -> Vec<u8> {
let img = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([
(x * 255 / width.max(1)) as u8,
(y * 255 / height.max(1)) as u8,
90,
])
});
let mut bytes = Vec::new();
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut bytes), format)
.unwrap();
bytes
}
#[test]
fn test_asset_names_are_validated() {
let hash = "a".repeat(64);
assert!(is_valid_asset_name(&format!("{hash}.png")));
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
assert!(!is_valid_asset_name("../../etc/passwd"));
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
assert!(!is_valid_asset_name(""));
let store = AssetStore::new(Path::new("/tmp/nb"));
assert!(store.path_of("../manifest.json").is_err());
}
#[test]
fn test_import_png_jpeg_and_deduplicate() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(dir.path());
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
let a = store.import_bytes(&png).unwrap();
assert_eq!((a.width, a.height), (40, 20));
assert!(a.asset.ends_with(".png"));
assert!(store.path_of(&a.asset).unwrap().exists());
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
let b = store.import_bytes(&png).unwrap();
assert_eq!(a, b);
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
// JPEG bleibt byte-identisch
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
let c = store.import_bytes(&jpg).unwrap();
assert!(c.asset.ends_with(".jpg"));
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
// BMP wird als PNG gespeichert
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
let d = store.import_bytes(&bmp).unwrap();
assert!(d.asset.ends_with(".png"));
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
assert_eq!((rgba.width, rgba.height), (40, 20));
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
// Verkleinerung für die Anzeige behält das Seitenverhältnis
let small = store.load_rgba(&a.asset, 10).unwrap();
assert_eq!((small.width, small.height), (10, 5));
}
#[test]
fn test_import_rejects_garbage_and_copies_between_notebooks() {
let dir = tempfile::tempdir().unwrap();
let store = AssetStore::new(&dir.path().join("a"));
assert!(matches!(
store.import_bytes(b"kein Bild"),
Err(AssetError::Decode(_))
));
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
let other = AssetStore::new(&dir.path().join("b"));
assert!(other.read_bytes(&imported.asset).is_err());
other.copy_from(&store, &imported.asset).unwrap();
assert_eq!(
other.read_bytes(&imported.asset).unwrap(),
store.read_bytes(&imported.asset).unwrap()
);
}
#[test]
fn test_supported_image_paths() {
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
assert!(is_supported_image_path(Path::new("a.webp")));
assert!(!is_supported_image_path(Path::new("a.pdf")));
assert!(!is_supported_image_path(Path::new("png")));
}
}
+899 -85
View File
File diff suppressed because it is too large. Load diff
+195 -36
View File
@@ -1,5 +1,7 @@
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko). //! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
use serde::{Deserialize, Serialize};
use crate::canvas::InkVertex; use crate::canvas::InkVertex;
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH}; use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
@@ -13,43 +15,122 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten). /// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
pub const LINED_SPACING: f32 = 75.0; pub const LINED_SPACING: f32 = 75.0;
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0). /// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0]; /// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF). ///
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; /// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub const DESK_COLOR: wgpu::Color = wgpu::Color { #[serde(rename_all = "lowercase")]
r: 0.16, pub enum PaperTheme {
g: 0.16, #[default]
b: 0.18, White,
a: 1.0, /// Cremefarben (augenschonend, wie Recyclingpapier)
}; Cream,
/// Dunkelgrau (Nachtmodus)
DarkGray,
/// Schwarz (OLED)
Black,
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt. impl PaperTheme {
pub fn generate_paper_quad() -> Vec<InkVertex> { pub const ALL: [PaperTheme; 4] = [
PaperTheme::White,
PaperTheme::Cream,
PaperTheme::DarkGray,
PaperTheme::Black,
];
pub fn label(self) -> &'static str {
match self {
PaperTheme::White => "Weiß",
PaperTheme::Cream => "Creme",
PaperTheme::DarkGray => "Dunkelgrau",
PaperTheme::Black => "Schwarz",
}
}
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
pub fn is_dark(self) -> bool {
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
}
/// Farbe des Blatts.
pub fn paper_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
// sRGB #FBF5E6
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
// sRGB #2B2B2E
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
}
}
/// Farbe von Karos, Linien und Punkten.
pub fn pattern_color(self) -> [f32; 4] {
match self {
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
// sRGB #48484D bzw. #38383C
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
}
}
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
/// das Blatt abhebt.
pub fn desk_color(self) -> wgpu::Color {
let (r, g, b) = match self {
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
PaperTheme::Black => (0.03, 0.03, 0.035),
};
wgpu::Color { r, g, b, a: 1.0 }
}
}
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
let max = color[0].max(color[1]).max(color[2]);
let min = color[0].min(color[1]).min(color[2]);
let lift = (1.0 - max - min).max(0.0);
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
}
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
vec![ vec![
InkVertex::new(0.0, 0.0, PAPER_COLOR), InkVertex::new(0.0, 0.0, color),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(0.0, PAGE_HEIGHT, color),
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR), InkVertex::new(PAGE_WIDTH, 0.0, color),
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR), InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
] ]
} }
/// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu. /// Fügt ein Quad (2 Dreiecke) zur Vertexliste hinzu.
#[inline] #[inline]
fn push_quad(vertices: &mut Vec<InkVertex>, x_min: f32, y_min: f32, x_max: f32, y_max: f32) { fn push_quad(
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR)); vertices: &mut Vec<InkVertex>,
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); color: [f32; 4],
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); x_min: f32,
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR)); y_min: f32,
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR)); x_max: f32,
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR)); y_max: f32,
) {
vertices.push(InkVertex::new(x_min, y_min, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_min, y_max, color));
vertices.push(InkVertex::new(x_max, y_min, color));
vertices.push(InkVertex::new(x_max, y_max, color));
} }
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund. /// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> { pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
match background { match background {
PageBackground::Blank => Vec::new(), PageBackground::Blank => Vec::new(),
PageBackground::Grid5mm => { PageBackground::Grid5mm => {
@@ -59,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
// Vertikale Linien alle 5 mm // Vertikale Linien alle 5 mm
let mut x = GRID_5MM_SPACING; let mut x = GRID_5MM_SPACING;
while x < PAGE_WIDTH { while x < PAGE_WIDTH {
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT); push_quad(
&mut vertices,
color,
x - half_t,
0.0,
x + half_t,
PAGE_HEIGHT,
);
x += GRID_5MM_SPACING; x += GRID_5MM_SPACING;
} }
// Horizontale Linien alle 5 mm // Horizontale Linien alle 5 mm
let mut y = GRID_5MM_SPACING; let mut y = GRID_5MM_SPACING;
while y < PAGE_HEIGHT { while y < PAGE_HEIGHT {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += GRID_5MM_SPACING; y += GRID_5MM_SPACING;
} }
@@ -82,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
let mut y = margin_top; let mut y = margin_top;
while y <= PAGE_HEIGHT - margin_bottom { while y <= PAGE_HEIGHT - margin_bottom {
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t); push_quad(
&mut vertices,
color,
0.0,
y - half_t,
PAGE_WIDTH,
y + half_t,
);
y += LINED_SPACING; y += LINED_SPACING;
} }
@@ -98,6 +200,7 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
while x < PAGE_WIDTH { while x < PAGE_WIDTH {
push_quad( push_quad(
&mut vertices, &mut vertices,
color,
x - half_d, x - half_d,
y - half_d, y - half_d,
x + half_d, x + half_d,
@@ -119,18 +222,20 @@ mod tests {
#[test] #[test]
fn test_paper_quad_generation() { fn test_paper_quad_generation() {
let quad = generate_paper_quad(); let quad = generate_paper_quad(PaperTheme::White.paper_color());
assert_eq!(quad.len(), 6); assert_eq!(quad.len(), 6);
assert_eq!(quad[0].position, [0.0, 0.0]); assert_eq!(quad[0].position, [0.0, 0.0]);
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]); assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
} }
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
#[test] #[test]
fn test_background_meshes() { fn test_background_meshes() {
let blank = generate_background_mesh(PageBackground::Blank); let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
assert!(blank.is_empty()); assert!(blank.is_empty());
let grid = generate_background_mesh(PageBackground::Grid5mm); let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
assert!(!grid.is_empty()); assert!(!grid.is_empty());
assert_eq!( assert_eq!(
grid.len() % 6, grid.len() % 6,
@@ -138,12 +243,66 @@ mod tests {
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)" "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
); );
let lined = generate_background_mesh(PageBackground::Lined); let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
assert!(!lined.is_empty()); assert!(!lined.is_empty());
assert_eq!(lined.len() % 6, 0); assert_eq!(lined.len() % 6, 0);
let dots = generate_background_mesh(PageBackground::DotGrid); let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
assert!(!dots.is_empty()); assert!(!dots.is_empty());
assert_eq!(dots.len() % 6, 0); assert_eq!(dots.len() % 6, 0);
} }
#[test]
fn test_background_mesh_uses_theme_color() {
let dark = PaperTheme::Black.pattern_color();
let mesh = generate_background_mesh(PageBackground::Lined, dark);
assert!(mesh.iter().all(|v| v.color == dark));
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
assert!(quad
.iter()
.all(|v| v.color == PaperTheme::Cream.paper_color()));
}
#[test]
fn test_adapt_ink_color() {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
// Schwarz wird Weiß, Deckkraft bleibt
assert!(close(
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
[1.0, 1.0, 1.0, 0.7]
));
// Notiz-Schwarz wird hell
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
assert!(ink[0] > 0.85 && ink[2] > 0.85);
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
for c in [
[0.85, 0.20, 0.20, 1.0],
[0.15, 0.35, 0.85, 1.0],
[1.0, 0.95, 0.1, 0.35],
[1.0, 1.0, 1.0, 1.0],
] {
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
}
// Ergebnis ist nie heller als Weiß
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
}
}
#[test]
fn test_paper_themes_contrast() {
for theme in PaperTheme::ALL {
let paper = theme.paper_color()[0];
let pattern = theme.pattern_color()[0];
assert!(
(paper - pattern).abs() > 0.03,
"{theme:?}: Muster hebt sich nicht vom Papier ab"
);
// Helles Papier ↔ dunkles Papier
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
}
}
} }
+221
View File
@@ -0,0 +1,221 @@
//! Crash-Simulationstesthelfer für ink-notes.
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
use std::env;
use std::path::Path;
use std::process;
use std::time::Duration;
use glam::Vec2;
use ink_notes::document::{Notebook, PageBackground, StylusSample};
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
use ink_notes::storage::{
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
NotebookManifest, PageData,
};
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
save_notebook(dir, &nb, &mut m)?;
println!("[init] Initialized clean notebook in {:?}", dir);
Ok(())
}
fn run_worker(dir: &Path) {
let (mut notebook, mut manifest) = match load_notebook(dir) {
Ok((nb, m)) => (nb, m),
Err(_) => {
let nb = Notebook::new();
let id = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("crash_test");
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
m.sync_from_notebook(&nb);
let _ = save_notebook(dir, &nb, &mut m);
(nb, m)
}
};
let spline_cfg = SplineConfig::default();
let mut stroke_counter = manifest.next_stroke_id;
println!("[worker] Started writing loop in {:?}", dir);
loop {
// Erzeuge neuen Strich mit einigen Samples
let base_x = (stroke_counter % 500) as f32;
let samples = vec![
StylusSample {
position: Vec2::new(base_x, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: Vec2::new(base_x + 10.0, 150.0),
pressure: 0.7,
timestamp: 0.01,
},
StylusSample {
position: Vec2::new(base_x + 20.0, 200.0),
pressure: 0.6,
timestamp: 0.02,
},
];
let stroke = finalize_stroke_with_config(
stroke_counter,
&samples,
[0.0, 0.0, 0.0, 1.0],
&spline_cfg,
);
stroke_counter += 1;
manifest.next_stroke_id = stroke_counter;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
let page_data = PageData::from(&*page);
// 1. Schreibe Seite atomar
if let Err(e) = save_page(dir, &page_data) {
eprintln!("[worker] Error saving page: {:?}", e);
}
// 2. Aktualisiere Manifest atomar
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
if stroke_counter % 5 == 0 {
if let Err(e) = save_notebook(dir, &notebook, &mut manifest) {
eprintln!("[worker] Error saving notebook: {:?}", e);
}
}
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
if stroke_counter % 25 == 0 {
let page_idx = notebook.add_page(PageBackground::DotGrid);
notebook.current_page_index = page_idx;
let new_page_data = PageData::from(notebook.current_page());
if let Err(e) = save_page(dir, &new_page_data) {
eprintln!("[worker] Error saving new page: {:?}", e);
}
manifest.sync_from_notebook(&notebook);
if let Err(e) = save_manifest(dir, &manifest) {
eprintln!("[worker] Error saving manifest: {:?}", e);
}
}
}
}
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
println!("[verify] Checking notebook in {:?}", dir);
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
let manifest_path = dir.join("manifest.json");
let manifest_bak = dir.join("manifest.json.bak");
if !manifest_path.exists() && !manifest_bak.exists() {
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
return Ok(());
}
// 2. Notizbuch laden
let (_notebook, manifest) = match load_notebook(dir) {
Ok(res) => res,
Err(e) => {
eprintln!("[verify] FAILED to load notebook: {:?}", e);
return Err(Box::new(e));
}
};
println!(
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
manifest.title,
manifest.pages.len(),
manifest.next_stroke_id
);
// 3. Alle im Manifest registrierten Seiten laden und prüfen
for page_meta in &manifest.pages {
match load_page(dir, page_meta.id) {
Ok(page_data) => {
let page = page_data.to_page();
println!(
"[verify] Page {} verified: {} strokes, background={:?}",
page_meta.id,
page.strokes.len(),
page.background
);
}
Err(e) => {
eprintln!(
"[verify] FAILED to load page {} referenced by manifest: {:?}",
page_meta.id, e
);
return Err(Box::new(e));
}
}
}
// 4. Prüfen ob Quarantäne-Dateien existieren
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.contains(".corrupt-") {
println!("[verify] Notice: Found quarantined file: {}", name);
}
}
}
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
Ok(())
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
process::exit(2);
}
let command = &args[1];
let notebook_dir = Path::new(&args[2]);
match command.as_str() {
"init" => match run_init(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[init] Error: {}", e);
process::exit(1);
}
},
"worker" => {
run_worker(notebook_dir);
}
"verify" => match run_verify(notebook_dir) {
Ok(_) => process::exit(0),
Err(e) => {
eprintln!("[verify] Error: {}", e);
process::exit(1);
}
},
other => {
eprintln!("Unknown command: {}", other);
process::exit(2);
}
}
}
+253 -49
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use winit::dpi::PhysicalSize; use winit::dpi::PhysicalSize;
use winit::window::Window; use winit::window::Window;
use crate::background::{generate_background_mesh, generate_paper_quad, DESK_COLOR}; use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample}; use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
use crate::filter::OneEuroFilter; use crate::filter::OneEuroFilter;
use crate::spline::{self, ControlPoint, SplineConfig}; use crate::spline::{self, ControlPoint, SplineConfig};
@@ -58,6 +58,8 @@ impl InkVertex {
pub struct CameraUniforms { pub struct CameraUniforms {
pub transform_scale: [f32; 2], pub transform_scale: [f32; 2],
pub transform_offset: [f32; 2], pub transform_offset: [f32; 2],
/// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung)
pub ink_params: [f32; 4],
} }
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0]. /// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
@@ -87,13 +89,43 @@ pub fn spline_vertices_to_ndc(
} }
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking. /// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
let preferred: &[wgpu::PresentMode] = if force_fifo {
&[wgpu::PresentMode::Fifo]
} else {
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
};
preferred
.iter()
.copied()
.find(|m| available.contains(m))
.or_else(|| available.first().copied())
}
pub struct CanvasState { pub struct CanvasState {
pub surface: wgpu::Surface<'static>, pub surface: wgpu::Surface<'static>,
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
adapter: wgpu::Adapter,
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
presentation_warned: bool,
pub device: wgpu::Device, pub device: wgpu::Device,
pub queue: wgpu::Queue, pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration, pub config: wgpu::SurfaceConfiguration,
pub size: PhysicalSize<u32>, pub size: PhysicalSize<u32>,
pub viewport: PageViewport, pub viewport: PageViewport,
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
ui_inset_left: f32,
ui_inset_right: f32,
pub camera: crate::viewport::ViewportState, pub camera: crate::viewport::ViewportState,
// Pipelines // Pipelines
@@ -104,6 +136,7 @@ pub struct CanvasState {
pub camera_buffer: wgpu::Buffer, pub camera_buffer: wgpu::Buffer,
pub camera_bind_group: wgpu::BindGroup, pub camera_bind_group: wgpu::BindGroup,
pub current_ink_color: [f32; 4], pub current_ink_color: [f32; 4],
pub current_stroke_width_mm: f32,
// Statische Hintergrund-Geometrie // Statische Hintergrund-Geometrie
pub paper_buffer: wgpu::Buffer, pub paper_buffer: wgpu::Buffer,
@@ -111,6 +144,10 @@ pub struct CanvasState {
pub background_vertex_capacity: usize, pub background_vertex_capacity: usize,
pub background_vertex_count: u32, pub background_vertex_count: u32,
pub current_background: PageBackground, pub current_background: PageBackground,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Tinte auf dunklem Papier aufhellen
pub adapt_ink: bool,
// Pipeline-Trennung: Live-Strich (aktiver Stroke) // Pipeline-Trennung: Live-Strich (aktiver Stroke)
pub live_vertex_buffer: wgpu::Buffer, pub live_vertex_buffer: wgpu::Buffer,
@@ -128,6 +165,9 @@ pub struct CanvasState {
pub committed_vertex_count: u32, pub committed_vertex_count: u32,
pub committed_strokes: Vec<Range<u32>>, pub committed_strokes: Vec<Range<u32>>,
// Eingefügte Bilder der aktuellen Seite
image_layer: crate::image_layer::ImageLayer,
// egui UI-Renderer // egui UI-Renderer
pub egui_renderer: egui_wgpu::Renderer, pub egui_renderer: egui_wgpu::Renderer,
} }
@@ -144,23 +184,47 @@ impl CanvasState {
let viewport = let viewport =
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN); PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
// 1. WGPU Instance & Surface // 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(), backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
..Default::default() ..Default::default()
}); });
let surface = instance.create_surface(window.clone())?; let surface = instance.create_surface(window.clone())?;
// 2. Adapter auswählen (hohe Performance bevorzugt) // 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
let adapter = instance // Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
.request_adapter(&wgpu::RequestAdapterOptions { // anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
power_preference: wgpu::PowerPreference::HighPerformance, let mut chosen = None;
compatible_surface: Some(&surface), for force_fallback_adapter in [false, true] {
force_fallback_adapter: false, let Some(adapter) = instance
}) .request_adapter(&wgpu::RequestAdapterOptions {
.await power_preference: wgpu::PowerPreference::HighPerformance,
.ok_or("Kein passender GPU-Adapter gefunden")?; compatible_surface: Some(&surface),
force_fallback_adapter,
})
.await
else {
continue;
};
let info = adapter.get_info();
let caps = surface.get_capabilities(&adapter);
match choose_present_mode(&caps.present_modes) {
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
eprintln!(
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
info.name, info.backend, mode
);
chosen = Some((adapter, caps, mode));
break;
}
_ => eprintln!(
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
info.name, info.backend
),
}
}
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
// 3. Device & Queue erstellen // 3. Device & Queue erstellen
let (device, queue) = adapter let (device, queue) = adapter
@@ -174,8 +238,7 @@ impl CanvasState {
) )
.await?; .await?;
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1 // 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps let surface_format = surface_caps
.formats .formats
.iter() .iter()
@@ -183,20 +246,6 @@ impl CanvasState {
.find(|f| f.is_srgb()) .find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]); .unwrap_or(surface_caps.formats[0]);
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
wgpu::PresentMode::Fifo
} else if surface_caps
.present_modes
.contains(&wgpu::PresentMode::Mailbox)
{
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
wgpu::PresentMode::Mailbox
} else {
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
wgpu::PresentMode::Fifo
};
let config = wgpu::SurfaceConfiguration { let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format, format: surface_format,
@@ -235,6 +284,7 @@ impl CanvasState {
let camera_uniforms = CameraUniforms { let camera_uniforms = CameraUniforms {
transform_scale: scale, transform_scale: scale,
transform_offset: offset, transform_offset: offset,
ink_params: [0.0; 4],
}; };
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor { let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Camera Uniform Buffer"), label: Some("Camera Uniform Buffer"),
@@ -271,7 +321,7 @@ impl CanvasState {
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
module: &shader, module: &shader,
entry_point: "fs_main", entry_point: "fs_ink",
targets: &[Some(wgpu::ColorTargetState { targets: &[Some(wgpu::ColorTargetState {
format: config.format, format: config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING), blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -325,7 +375,8 @@ impl CanvasState {
}); });
// 6. Statische Buffer // 6. Statische Buffer
let paper_quad = generate_paper_quad(); let paper_theme = PaperTheme::default();
let paper_quad = generate_paper_quad(paper_theme.paper_color());
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor { let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Paper Quad Buffer"), label: Some("Paper Quad Buffer"),
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64, size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
@@ -335,7 +386,7 @@ impl CanvasState {
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad)); queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
let current_background = PageBackground::Grid5mm; let current_background = PageBackground::Grid5mm;
let bg_mesh = generate_background_mesh(current_background); let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor { let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Background Mesh Buffer"), label: Some("Background Mesh Buffer"),
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64, size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
@@ -359,26 +410,40 @@ impl CanvasState {
mapped_at_creation: false, mapped_at_creation: false,
}); });
let image_layer = crate::image_layer::ImageLayer::new(
&device,
&shader,
&bind_group_layout,
config.format,
);
// 8. egui Renderer für das schwebende UI initialisieren // 8. egui Renderer für das schwebende UI initialisieren
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1); let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
Ok(Self { Ok(Self {
surface, surface,
adapter,
presentation_warned: false,
device, device,
queue, queue,
config, config,
size, size,
viewport, viewport,
ui_inset_left: 0.0,
ui_inset_right: 0.0,
strip_pipeline, strip_pipeline,
triangle_pipeline, triangle_pipeline,
camera_buffer, camera_buffer,
camera_bind_group, camera_bind_group,
current_ink_color: default_ink_color, current_ink_color: default_ink_color,
current_stroke_width_mm: 0.5,
paper_buffer, paper_buffer,
background_buffer, background_buffer,
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY, background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
background_vertex_count: bg_mesh.len() as u32, background_vertex_count: bg_mesh.len() as u32,
current_background, current_background,
paper_theme,
adapt_ink: false,
live_vertex_buffer, live_vertex_buffer,
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY, live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
live_vertex_count: 0, live_vertex_count: 0,
@@ -395,6 +460,7 @@ impl CanvasState {
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY, committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0, committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128), committed_strokes: Vec::with_capacity(128),
image_layer,
egui_renderer, egui_renderer,
camera: crate::viewport::ViewportState::new(), camera: crate::viewport::ViewportState::new(),
}) })
@@ -406,6 +472,16 @@ impl CanvasState {
let camera_uniforms = CameraUniforms { let camera_uniforms = CameraUniforms {
transform_scale: scale, transform_scale: scale,
transform_offset: offset, transform_offset: offset,
ink_params: [
if self.adapt_ink && self.paper_theme.is_dark() {
1.0
} else {
0.0
},
0.0,
0.0,
0.0,
],
}; };
self.queue.write_buffer( self.queue.write_buffer(
&self.camera_buffer, &self.camera_buffer,
@@ -414,21 +490,54 @@ impl CanvasState {
); );
} }
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
return false;
}
self.ui_inset_left = left;
self.ui_inset_right = right;
self.recalculate_viewport();
true
}
fn recalculate_viewport(&mut self) {
self.viewport = PageViewport::calculate_with_insets(
self.size.width as f32,
self.size.height as f32,
Self::VIEWPORT_MARGIN,
self.ui_inset_left,
self.ui_inset_right,
);
self.update_uniforms();
}
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an. /// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
pub fn resize(&mut self, new_size: PhysicalSize<u32>) { pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
if new_size.width > 0 && new_size.height > 0 { if new_size.width > 0 && new_size.height > 0 {
self.size = new_size; self.size = new_size;
self.config.width = new_size.width; self.config.width = new_size.width;
self.config.height = new_size.height; self.config.height = new_size.height;
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
let caps = self.surface.get_capabilities(&self.adapter);
if !caps.present_modes.contains(&self.config.present_mode) {
match choose_present_mode(&caps.present_modes) {
Some(mode) => self.config.present_mode = mode,
None => {
if !self.presentation_warned {
eprintln!(
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
NO_USABLE_GPU_MESSAGE
);
self.presentation_warned = true;
}
return;
}
}
}
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
self.recalculate_viewport();
self.viewport = PageViewport::calculate(
new_size.width as f32,
new_size.height as f32,
Self::VIEWPORT_MARGIN,
);
self.update_uniforms();
} }
} }
@@ -502,10 +611,24 @@ impl CanvasState {
/// Konfiguriert die Strichbreite in Millimetern. /// Konfiguriert die Strichbreite in Millimetern.
pub fn set_stroke_width_mm(&mut self, width_mm: f32) { pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0. self.current_stroke_width_mm = width_mm;
let base_radius = (width_mm * 5.0).max(0.25); self.spline_config =
self.spline_config.min_radius = (base_radius * 0.5).max(0.15); SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
self.spline_config.max_radius = (base_radius * 1.5).max(0.35); }
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
self.adapt_ink = adapt_ink;
if self.paper_theme != theme {
self.paper_theme = theme;
self.queue.write_buffer(
&self.paper_buffer,
0,
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
);
self.upload_background_mesh();
}
self.update_uniforms();
} }
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
@@ -514,7 +637,13 @@ impl CanvasState {
return; return;
} }
self.current_background = background; self.current_background = background;
let mesh = generate_background_mesh(background); self.upload_background_mesh();
}
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
fn upload_background_mesh(&mut self) {
let mesh =
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
self.background_vertex_count = mesh.len() as u32; self.background_vertex_count = mesh.len() as u32;
if mesh.len() > self.background_vertex_capacity { if mesh.len() > self.background_vertex_capacity {
@@ -537,6 +666,18 @@ impl CanvasState {
pub fn sync_page(&mut self, page: &mut Page) { pub fn sync_page(&mut self, page: &mut Page) {
page.rebuild_cache_if_dirty(); page.rebuild_cache_if_dirty();
self.set_background(page.background); self.set_background(page.background);
// PDF-Seite zuerst, darüber die eingefügten Bilder
self.image_layer.set_quads(
page.pdf_background
.iter()
.map(|pdf| (pdf.key(), pdf.corners()))
.chain(
page.strokes
.iter()
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
)
.collect(),
);
self.committed_vertex_count = page.vertex_cache.len() as u32; self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone(); self.committed_strokes = page.stroke_ranges.clone();
@@ -563,6 +704,20 @@ impl CanvasState {
); );
} }
/// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen.
/// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts.
pub fn prepare_images(
&mut self,
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
) {
self.image_layer.prepare(&self.device, &self.queue, load);
}
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
pub fn is_waiting_for_images(&self) -> bool {
self.image_layer.is_waiting()
}
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten. /// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) { pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
self.filter.reset(); self.filter.reset();
@@ -643,6 +798,9 @@ impl CanvasState {
self.current_ink_color, self.current_ink_color,
&self.spline_config, &self.spline_config,
); );
let mut stroke = stroke;
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
stroke.finalized = true;
// Temporären GPU-Live-Buffer vollständig leeren // Temporären GPU-Live-Buffer vollständig leeren
self.live_samples.clear(); self.live_samples.clear();
@@ -736,7 +894,7 @@ impl CanvasState {
view: &view, view: &view,
resolve_target: None, resolve_target: None,
ops: wgpu::Operations { ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
store: wgpu::StoreOp::Store, store: wgpu::StoreOp::Store,
}, },
})], })],
@@ -748,7 +906,7 @@ impl CanvasState {
// 2D-Kamera Uniforms (Scale & Offset) binden // 2D-Kamera Uniforms (Scale & Offset) binden
render_pass.set_bind_group(0, &self.camera_bind_group, &[]); render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
// 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt) // 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
render_pass.set_pipeline(&self.triangle_pipeline); render_pass.set_pipeline(&self.triangle_pipeline);
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..)); render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
render_pass.draw(0..6, 0..1); render_pass.draw(0..6, 0..1);
@@ -759,7 +917,10 @@ impl CanvasState {
render_pass.draw(0..self.background_vertex_count, 0..1); render_pass.draw(0..self.background_vertex_count, 0..1);
} }
// 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben) // 3. Eingefügte Bilder (unter der Tinte)
self.image_layer.draw(&mut render_pass);
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
render_pass.set_pipeline(&self.strip_pipeline); render_pass.set_pipeline(&self.strip_pipeline);
if self.committed_vertex_count > 0 { if self.committed_vertex_count > 0 {
@@ -771,7 +932,7 @@ impl CanvasState {
} }
} }
// 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency) // 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
if self.live_vertex_count > 0 { if self.live_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..)); render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
render_pass.draw(0..self.live_vertex_count, 0..1); render_pass.draw(0..self.live_vertex_count, 0..1);
@@ -825,6 +986,16 @@ impl CanvasState {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_choose_present_mode() {
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
assert_eq!(choose_present_mode(&[]), None);
}
#[test] #[test]
fn test_pixel_to_ndc_mapping() { fn test_pixel_to_ndc_mapping() {
let width = 800.0; let width = 800.0;
@@ -850,7 +1021,40 @@ mod tests {
#[test] #[test]
fn test_camera_uniforms() { fn test_camera_uniforms() {
let _u = CameraUniforms::default(); let _u = CameraUniforms::default();
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16); // Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4
assert_eq!(std::mem::size_of::<CameraUniforms>(), 32);
}
#[test]
fn test_shader_is_valid_and_matches_uniforms() {
// Ein Shaderfehler fiele sonst erst beim Programmstart auf
let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl"))
.unwrap_or_else(|e| panic!("WGSL-Fehler: {e}"));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
assert!(
module.entry_points.iter().any(|ep| ep.name == name),
"Einstiegspunkt {name} fehlt"
);
}
let camera = module
.types
.iter()
.find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms"))
.expect("CameraUniforms fehlt im Shader")
.1;
match camera.inner {
naga::TypeInner::Struct { span, .. } => {
assert_eq!(span as usize, std::mem::size_of::<CameraUniforms>())
}
ref other => panic!("CameraUniforms ist kein Struct: {other:?}"),
}
} }
#[test] #[test]
+801 -13
View File
@@ -103,6 +103,258 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
(p - projection).length() (p - projection).length()
} }
/// Liegt `p` im konvexen Viereck `quad` (Ecken der Reihe nach, beliebiger Umlaufsinn)?
pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
let mut sign = 0.0_f32;
for i in 0..4 {
let a = quad[i];
let b = quad[(i + 1) % 4];
let cross = (b - a).perp_dot(p - a);
if cross.abs() < 1e-6 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
true
}
/// Größte Länge eines Seitentitels in Zeichen.
pub const MAX_PAGE_TITLE_CHARS: usize = 120;
/// Bereinigt einen Seitentitel: Zeilenumbrüche und doppelte Leerzeichen entfernen, auf
/// [`MAX_PAGE_TITLE_CHARS`] kürzen; leer bedeutet „kein Titel“.
pub fn normalize_page_title(title: &str) -> Option<String> {
let cleaned: String = title
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(MAX_PAGE_TITLE_CHARS)
.collect();
(!cleaned.is_empty()).then_some(cleaned)
}
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PdfBackground {
/// Name der PDF-Datei im Asset-Ordner
pub asset: String,
/// Seitennummer im PDF, ab 0
pub page: u32,
/// Angezeigte Größe der PDF-Seite in Punkt (Drehung berücksichtigt)
pub width_pt: f32,
pub height_pt: f32,
}
impl PdfBackground {
/// Schlüssel für Texturen und Render-Aufträge.
pub fn key(&self) -> String {
format!("{}#{}", self.asset, self.page)
}
/// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf dem
/// A4-Blatt, siehe [`pdf_placement`].
pub fn corners(&self) -> [glam::Vec2; 4] {
pdf_placement(self.width_pt, self.height_pt)
}
}
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf dem A4-Blatt: so groß wie möglich,
/// waagerecht zentriert und oben bündig. Querformat-Folien liegen so oben auf der Seite,
/// darunter bleibt Platz für Notizen.
pub fn pdf_placement(width_pt: f32, height_pt: f32) -> [glam::Vec2; 4] {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h);
let size = glam::Vec2::new(w * scale, h * scale);
let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0);
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
tl + size,
tl + glam::Vec2::new(0.0, size.y),
]
}
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt.
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] {
let natural =
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
let fit = (PAGE_WIDTH * 0.8 / natural.x)
.min(PAGE_HEIGHT * 0.6 / natural.y)
.min(1.0);
let size = natural * fit;
let half = size / 2.0;
let center = glam::Vec2::new(
center.x.clamp(half.x, PAGE_WIDTH - half.x),
center.y.clamp(half.y, PAGE_HEIGHT - half.y),
);
let tl = center - half;
[
tl,
tl + glam::Vec2::new(size.x, 0.0),
tl + size,
tl + glam::Vec2::new(0.0, size.y),
]
}
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StrokeTool {
#[default]
Pen,
Highlighter,
Shape,
}
impl StrokeTool {
/// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und
/// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der
/// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form.
pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool {
match stored {
StrokeTool::Pen if has_shape => StrokeTool::Shape,
StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter,
other => other,
}
}
}
/// Welche Striche der Radierer entfernt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EraserMode {
/// Alle getroffenen Striche
#[default]
All,
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
HighlighterOnly,
/// Nur den überstrichenen Teil eines Strichs entfernen; der Rest bleibt in Stücken
/// erhalten. Formen werden als Ganzes radiert.
Partial,
}
impl EraserMode {
/// Darf ein Strich dieses Werkzeugs radiert werden?
pub fn erases(self, tool: StrokeTool) -> bool {
match self {
EraserMode::All | EraserMode::Partial => true,
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
}
}
}
/// Zeitfenster, in dem aufeinanderfolgende Teilradierungen zu einem Undo-Schritt
/// zusammengefasst werden (ein Radierzug besteht aus vielen kleinen Schnitten).
pub const PARTIAL_ERASE_MERGE_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
/// Zerlegt die Punktfolge eines Strichs an einem Kreis (Radierer): übrig bleiben die Stücke
/// außerhalb des Kreises, an den Kreisrand gekürzt. `None`, wenn der Kreis den Strich nicht
/// berührt. Stücke kürzer als `min_length` entfallen.
pub fn split_samples_at_circle(
samples: &[StylusSample],
center: glam::Vec2,
radius: f32,
min_length: f32,
) -> Option<Vec<Vec<StylusSample>>> {
let inside = |p: glam::Vec2| (p - center).length_squared() <= radius * radius;
let lerp = |a: &StylusSample, b: &StylusSample, t: f32| StylusSample {
position: a.position.lerp(b.position, t),
pressure: a.pressure + (b.pressure - a.pressure) * t,
timestamp: a.timestamp + (b.timestamp - a.timestamp) * t as f64,
};
// Schnittparameter t ∈ (0, 1) einer Strecke mit dem Kreisrand, aufsteigend
let crossings = |a: glam::Vec2, b: glam::Vec2| -> Vec<f32> {
let d = b - a;
let f = a - center;
let qa = d.dot(d);
if qa < 1e-12 {
return Vec::new();
}
let qb = 2.0 * f.dot(d);
let qc = f.dot(f) - radius * radius;
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
return Vec::new();
}
let root = disc.sqrt();
[(-qb - root) / (2.0 * qa), (-qb + root) / (2.0 * qa)]
.into_iter()
.filter(|t| *t > 0.0 && *t < 1.0)
.collect()
};
match samples {
[] => return None,
[single] => return inside(single.position).then(Vec::new),
_ => {}
}
let touched = samples.iter().any(|s| inside(s.position))
|| samples
.windows(2)
.any(|w| crossings(w[0].position, w[1].position).len() == 2);
if !touched {
return None;
}
let mut pieces = Vec::new();
let mut current: Vec<StylusSample> = Vec::new();
if !inside(samples[0].position) {
current.push(samples[0]);
}
for w in samples.windows(2) {
let (a, b) = (&w[0], &w[1]);
let (a_in, b_in) = (inside(a.position), inside(b.position));
let ts = crossings(a.position, b.position);
match (a_in, b_in) {
(false, false) => {
if let [t_enter, t_exit] = ts[..] {
// Strecke läuft durch den Kreis
current.push(lerp(a, b, t_enter));
pieces.push(std::mem::take(&mut current));
current.push(lerp(a, b, t_exit));
}
current.push(*b);
}
(false, true) => {
if let Some(&t) = ts.first() {
current.push(lerp(a, b, t));
}
pieces.push(std::mem::take(&mut current));
}
(true, false) => {
current.clear();
if let Some(&t) = ts.last() {
current.push(lerp(a, b, t));
}
current.push(*b);
}
(true, true) => {}
}
}
pieces.push(current);
let length = |p: &Vec<StylusSample>| -> f32 {
p.windows(2)
.map(|w| (w[1].position - w[0].position).length())
.sum()
};
Some(
pieces
.into_iter()
.filter(|p| p.len() >= 2 && length(p) >= min_length)
.collect(),
)
}
/// Ein einzelner Vektor-Strich auf einer Seite. /// Ein einzelner Vektor-Strich auf einer Seite.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Stroke { pub struct Stroke {
@@ -112,6 +364,13 @@ pub struct Stroke {
pub aabb: Aabb, pub aabb: Aabb,
pub color: [f32; 4], pub color: [f32; 4],
pub shape: Option<crate::geometry::RecognizedShape>, pub shape: Option<crate::geometry::RecognizedShape>,
pub tool: StrokeTool,
pub width: f32,
pub finalized: bool,
/// Eingefügtes Bild (Name im Asset-Ordner, siehe `assets`). Die vier Punkte sind dann die
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
pub image: Option<String>,
} }
impl Stroke { impl Stroke {
@@ -141,9 +400,47 @@ impl Stroke {
aabb, aabb,
color, color,
shape: None, shape: None,
tool: StrokeTool::Pen,
width: 5.0,
finalized: true,
image: None,
} }
} }
/// Erzeugt ein Bildobjekt mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn new_image(id: u64, asset: String, corners: [glam::Vec2; 4]) -> Self {
let points = corners
.iter()
.map(|&position| StylusSample {
position,
pressure: 1.0,
timestamp: 0.0,
})
.collect();
let mut stroke = Self::new(id, points, Vec::new(), [1.0, 1.0, 1.0, 1.0]);
stroke.width = 0.0;
stroke.image = Some(asset);
stroke
}
/// Ist dieses Objekt ein Bild?
pub fn is_image(&self) -> bool {
self.image.is_some()
}
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
if self.image.is_none() || self.points.len() < 4 {
return None;
}
Some([
self.points[0].position,
self.points[1].position,
self.points[2].position,
self.points[3].position,
])
}
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell). /// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
pub fn finalize( pub fn finalize(
id: u64, id: u64,
@@ -157,11 +454,36 @@ impl Stroke {
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form. /// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self { pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
self.shape = Some(shape); self.shape = Some(shape);
self.tool = StrokeTool::Shape;
self
}
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
self.tool = tool;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn with_finalized(mut self, finalized: bool) -> Self {
self.finalized = finalized;
self self
} }
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich. /// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 { pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
if let Some(corners) = self.image_corners() {
if point_in_convex_quad(p, &corners) {
return 0.0;
}
return (0..4)
.map(|i| point_to_segment_distance(p, corners[i], corners[(i + 1) % 4]))
.fold(f32::INFINITY, f32::min);
}
if self.points.is_empty() { if self.points.is_empty() {
return f32::INFINITY; return f32::INFINITY;
} }
@@ -283,7 +605,11 @@ impl Stroke {
} }
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben. /// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
/// Bilder behalten ihre Farben.
pub fn set_color(&mut self, new_color: [f32; 4]) { pub fn set_color(&mut self, new_color: [f32; 4]) {
if self.is_image() {
return;
}
self.color = new_color; self.color = new_color;
for v in &mut self.vertices { for v in &mut self.vertices {
v.color = new_color; v.color = new_color;
@@ -304,16 +630,37 @@ pub enum PageBackground {
DotGrid, DotGrid,
} }
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PersistState {
#[default]
Normal,
ReadOnlyAfterLoadError,
}
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
#[derive(Debug, Clone, PartialEq)]
pub struct PageLoadWarning {
pub page_id: u64,
pub message: String,
pub quarantine_path: Option<std::path::PathBuf>,
}
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes. /// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Page { pub struct Page {
pub id: u64, pub id: u64,
pub strokes: Vec<Stroke>, pub strokes: Vec<Stroke>,
pub background: PageBackground, pub background: PageBackground,
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
pub pdf_background: Option<PdfBackground>,
/// Seitentitel (Lesezeichen); auch im Manifest, damit er ohne Laden der Seite bekannt ist
pub title: Option<String>,
pub vertex_cache: Vec<InkVertex>, pub vertex_cache: Vec<InkVertex>,
pub stroke_ranges: Vec<Range<u32>>, pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool, pub is_dirty: bool,
pub is_loaded: bool, pub is_loaded: bool,
pub persist: PersistState,
pub history: HistoryStack, pub history: HistoryStack,
} }
@@ -323,10 +670,13 @@ impl Page {
id, id,
strokes: Vec::new(), strokes: Vec::new(),
background, background,
pdf_background: None,
title: None,
vertex_cache: Vec::new(), vertex_cache: Vec::new(),
stroke_ranges: Vec::new(), stroke_ranges: Vec::new(),
is_dirty: false, is_dirty: false,
is_loaded: true, is_loaded: true,
persist: PersistState::Normal,
history: HistoryStack::new(), history: HistoryStack::new(),
} }
} }
@@ -337,14 +687,25 @@ impl Page {
id, id,
strokes: Vec::new(), strokes: Vec::new(),
background, background,
pdf_background: None,
title: None,
vertex_cache: Vec::new(), vertex_cache: Vec::new(),
stroke_ranges: Vec::new(), stroke_ranges: Vec::new(),
is_dirty: false, is_dirty: false,
is_loaded: false, is_loaded: false,
persist: PersistState::Normal,
history: HistoryStack::new(), history: HistoryStack::new(),
} }
} }
pub fn is_readonly(&self) -> bool {
self.persist == PersistState::ReadOnlyAfterLoadError
}
pub fn unlock_after_load_error(&mut self) {
self.persist = PersistState::Normal;
}
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack. /// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
pub fn add_stroke(&mut self, stroke: Stroke) { pub fn add_stroke(&mut self, stroke: Stroke) {
self.history.push(HistoryAction::AddStroke(stroke.clone())); self.history.push(HistoryAction::AddStroke(stroke.clone()));
@@ -370,20 +731,18 @@ impl Page {
} }
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius. /// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> { pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
let mut best: Option<(&Stroke, f32)> = None; let mut best: Option<(&Stroke, (bool, f32))> = None;
for s in &self.strokes { for s in &self.strokes {
if !s.aabb.expanded(max_dist).contains_point(pos) { if !s.aabb.expanded(max_dist).contains_point(pos) {
continue; continue;
} }
let d = s.distance_to_point(pos); let d = s.distance_to_point(pos);
if d <= max_dist { if d <= max_dist {
if let Some((_, best_d)) = best { let key = (s.is_image(), d);
if d < best_d { if best.is_none_or(|(_, best_key)| key < best_key) {
best = Some((s, d)); best = Some((s, key));
}
} else {
best = Some((s, d));
} }
} }
} }
@@ -410,7 +769,8 @@ impl Page {
} }
} }
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`. /// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`,
/// die `mode` zulässt.
/// ///
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden). /// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
pub fn erase_at( pub fn erase_at(
@@ -418,11 +778,15 @@ impl Page {
eraser_pos: glam::Vec2, eraser_pos: glam::Vec2,
eraser_radius: f32, eraser_radius: f32,
max_stroke_radius: f32, max_stroke_radius: f32,
mode: EraserMode,
) -> Vec<Stroke> { ) -> Vec<Stroke> {
let mut hit_indices = Vec::new(); let mut hit_indices = Vec::new();
for (i, stroke) in self.strokes.iter().enumerate() { for (i, stroke) in self.strokes.iter().enumerate() {
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) { if !stroke.is_image()
&& mode.erases(stroke.tool)
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
{
hit_indices.push(i); hit_indices.push(i);
} }
} }
@@ -445,6 +809,66 @@ impl Page {
removed removed
} }
/// Teilradierer: entfernt den Teil aller Striche im Kreis um `eraser_pos`. Getroffene
/// Striche werden durch ihre übrig gebliebenen Stücke ersetzt (neue IDs aus `next_id`),
/// Formen als Ganzes entfernt, Bilder nie. Aufeinanderfolgende Schnitte eines Radierzugs
/// bilden einen gemeinsamen Undo-Schritt. Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_partial_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
next_id: &mut u64,
) -> bool {
let mut removed = Vec::new();
let mut added = Vec::new();
let mut result = Vec::with_capacity(self.strokes.len());
for stroke in std::mem::take(&mut self.strokes) {
let reach = eraser_radius + stroke.width / 2.0;
if stroke.is_image() || !stroke.aabb.expanded(reach).contains_point(eraser_pos) {
result.push(stroke);
continue;
}
if stroke.shape.is_some() {
// Formen werden ganz radiert, sobald der Radierer sie berührt
if stroke.distance_to_point(eraser_pos) <= reach {
removed.push(stroke);
} else {
result.push(stroke);
}
continue;
}
match split_samples_at_circle(&stroke.points, eraser_pos, reach, stroke.width * 0.5) {
None => result.push(stroke),
Some(pieces) => {
for samples in pieces {
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
stroke.color,
stroke.width,
stroke.tool,
true,
);
let mut piece = Stroke::new(*next_id, samples, vertices, stroke.color);
*next_id += 1;
piece.tool = stroke.tool;
piece.width = stroke.width;
piece.finalized = true;
result.push(piece.clone());
added.push(piece);
}
removed.push(stroke);
}
}
}
self.strokes = result;
if removed.is_empty() {
return false;
}
self.history.push_split(removed, added);
self.is_dirty = true;
true
}
/// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist. /// Baut den gecachten Vertex-Puffer der Seite neu auf, wenn `is_dirty` gesetzt ist.
pub fn rebuild_cache_if_dirty(&mut self) { pub fn rebuild_cache_if_dirty(&mut self) {
if !self.is_dirty { if !self.is_dirty {
@@ -476,6 +900,8 @@ impl Page {
} }
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen. /// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> { pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
let sel_box = Aabb::new( let sel_box = Aabb::new(
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)), glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
@@ -483,7 +909,13 @@ impl Page {
); );
self.strokes self.strokes
.iter() .iter()
.filter(|s| s.aabb.intersects(&sel_box)) .filter(|s| {
if s.is_image() {
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
} else {
s.aabb.intersects(&sel_box)
}
})
.map(|s| s.id) .map(|s| s.id)
.collect() .collect()
} }
@@ -587,6 +1019,7 @@ pub struct Notebook {
pub current_page_index: usize, pub current_page_index: usize,
pub next_stroke_id: u64, pub next_stroke_id: u64,
pub next_page_id: u64, pub next_page_id: u64,
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
} }
impl Notebook { impl Notebook {
@@ -596,6 +1029,7 @@ impl Notebook {
current_page_index: 0, current_page_index: 0,
next_stroke_id: 1, next_stroke_id: 1,
next_page_id: 1, next_page_id: 1,
load_warnings: std::collections::HashMap::new(),
}; };
// Erste Standardseite anlegen (5mm Grid) // Erste Standardseite anlegen (5mm Grid)
nb.add_page(PageBackground::Grid5mm); nb.add_page(PageBackground::Grid5mm);
@@ -614,6 +1048,7 @@ impl Notebook {
current_page_index, current_page_index,
next_stroke_id, next_stroke_id,
next_page_id, next_page_id,
load_warnings: std::collections::HashMap::new(),
}; };
if nb.pages.is_empty() { if nb.pages.is_empty() {
nb.add_page(PageBackground::Grid5mm); nb.add_page(PageBackground::Grid5mm);
@@ -634,6 +1069,25 @@ impl Notebook {
&mut self.pages[self.current_page_index] &mut self.pages[self.current_page_index]
} }
/// Radiert auf der aktuellen Seite gemäß `mode` (ganze Striche, nur Textmarker oder
/// Teilstriche). Gibt `true` zurück, wenn sich etwas geändert hat.
pub fn erase_at(
&mut self,
eraser_pos: glam::Vec2,
eraser_radius: f32,
max_stroke_radius: f32,
mode: EraserMode,
) -> bool {
let page = &mut self.pages[self.current_page_index];
if mode == EraserMode::Partial {
page.erase_partial_at(eraser_pos, eraser_radius, &mut self.next_stroke_id)
} else {
!page
.erase_at(eraser_pos, eraser_radius, max_stroke_radius, mode)
.is_empty()
}
}
/// Erzeugt eine eindeutige neue Stroke-ID. /// Erzeugt eine eindeutige neue Stroke-ID.
pub fn next_stroke_id(&mut self) -> u64 { pub fn next_stroke_id(&mut self) -> u64 {
let id = self.next_stroke_id; let id = self.next_stroke_id;
@@ -680,6 +1134,20 @@ impl Notebook {
} }
} }
/// Setzt den Titel der Seite `page_id` (leer = kein Titel). `true`, wenn er sich geändert
/// hat.
pub fn set_page_title(&mut self, page_id: u64, title: &str) -> bool {
let title = normalize_page_title(title);
let Some(page) = self.pages.iter_mut().find(|p| p.id == page_id) else {
return false;
};
if page.title == title || !page.is_loaded || page.is_readonly() {
return false;
}
page.title = title;
true
}
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12". /// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
pub fn osd_text(&self) -> String { pub fn osd_text(&self) -> String {
format!( format!(
@@ -710,7 +1178,21 @@ pub struct PageViewport {
impl PageViewport { impl PageViewport {
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses. /// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self { pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
let avail_w = (window_width - 2.0 * margin).max(10.0); Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
}
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
pub fn calculate_with_insets(
window_width: f32,
window_height: f32,
margin: f32,
inset_left: f32,
inset_right: f32,
) -> Self {
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 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 window_ratio = avail_w / avail_h; let window_ratio = avail_w / avail_h;
@@ -728,7 +1210,7 @@ impl PageViewport {
(w, h) (w, h)
}; };
let page_x = (window_width - page_w) / 2.0; let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
let page_y = (window_height - page_h) / 2.0; let page_y = (window_height - page_h) / 2.0;
Self { Self {
@@ -869,6 +1351,25 @@ mod tests {
assert_eq!(nb.current_page_index, 0); assert_eq!(nb.current_page_index, 0);
} }
#[test]
fn test_viewport_insets_center_page_in_free_area() {
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
assert!((inset.page_width - plain.page_width).abs() < 0.01);
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
// Leiste rechts
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
}
#[test] #[test]
fn test_page_viewport_projection() { fn test_page_viewport_projection() {
let vp = PageViewport::calculate(1600.0, 900.0, 20.0); let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
@@ -945,7 +1446,7 @@ mod tests {
assert_eq!(page.strokes.len(), 2); assert_eq!(page.strokes.len(), 2);
// Radieren bei (30, 12): Trifft nur s1 // Radieren bei (30, 12): Trifft nur s1
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0); let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All);
assert_eq!(removed.len(), 1); assert_eq!(removed.len(), 1);
assert_eq!(removed[0].id, 1); assert_eq!(removed[0].id, 1);
assert_eq!(page.strokes.len(), 1); assert_eq!(page.strokes.len(), 1);
@@ -961,6 +1462,293 @@ mod tests {
assert_eq!(page.strokes[0].id, 2); assert_eq!(page.strokes[0].id, 2);
} }
#[test]
fn test_erase_highlighter_only_keeps_handwriting() {
let mut page = Page::new(1, PageBackground::Grid5mm);
let line = |id: u64, tool: StrokeTool| {
let mut stroke = Stroke::new(
id,
vec![
StylusSample {
position: glam::Vec2::new(10.0, 10.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(50.0, 10.0),
pressure: 0.5,
timestamp: 0.01,
},
],
vec![
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
],
[0.0, 0.0, 0.0, 1.0],
);
stroke.tool = tool;
stroke
};
// Handschrift, Form und Textmarker liegen übereinander
page.add_stroke(line(1, StrokeTool::Pen));
page.add_stroke(line(2, StrokeTool::Highlighter));
page.add_stroke(line(3, StrokeTool::Shape));
let at = glam::Vec2::new(30.0, 10.0);
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert_eq!(
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
vec![1, 3]
);
// Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag
assert!(page
.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly)
.is_empty());
assert!(page.undo());
assert_eq!(page.strokes.len(), 3);
// Normaler Radierer entfernt alles
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All);
assert_eq!(removed.len(), 3);
assert!(page.strokes.is_empty());
}
#[test]
fn test_place_image() {
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
// Kleines Bild: Bildschirmgröße, zentriert
let [tl, tr, br, bl] = place_image(200, 100, center);
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
let [tl, tr, br, _] = place_image(3840, 2160, center);
let size = br - tl;
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
assert_eq!(tr.y, tl.y);
// Am Rand eingefügt: wird aufs Blatt geschoben
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0));
for c in corners {
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
}
}
#[test]
fn test_image_objects_hit_test_and_survive_erasing() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
glam::Vec2::new(100.0, 100.0),
glam::Vec2::new(300.0, 100.0),
glam::Vec2::new(300.0, 200.0),
glam::Vec2::new(100.0, 200.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
assert_eq!(page.strokes[0].image_corners(), Some(corners));
// Fläche des Bildes trifft, auch weit weg vom Rand
let inside = glam::Vec2::new(200.0, 150.0);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(1));
assert!(page
.find_stroke_at(glam::Vec2::new(400.0, 150.0), 5.0)
.is_none());
// Notiz auf dem Bild hat beim Antippen Vorrang
let mut ink = Stroke::new(
2,
vec![
StylusSample {
position: glam::Vec2::new(150.0, 150.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(250.0, 152.0),
pressure: 0.5,
timestamp: 0.1,
},
],
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
ink.width = 5.0;
page.add_stroke(ink);
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(2));
// Radierer entfernt nur die Notiz, nie das Bild
let removed = page.erase_at(inside, 30.0, 12.0, EraserMode::All);
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
assert!(page
.erase_at(glam::Vec2::new(100.0, 100.0), 30.0, 12.0, EraserMode::All)
.is_empty());
// Auswahlrechteck: Bild nur bei vollständigem Einschluss
assert!(page
.find_strokes_in_box(glam::Vec2::new(150.0, 120.0), glam::Vec2::new(250.0, 180.0))
.is_empty());
assert_eq!(
page.find_strokes_in_box(glam::Vec2::new(90.0, 90.0), glam::Vec2::new(310.0, 210.0)),
vec![1]
);
// Umfärben ändert Bilder nicht, Drehen bewegt die Ecken mit
let mut image = page.strokes[0].clone();
image.set_color([1.0, 0.0, 0.0, 1.0]);
assert_eq!(image.color, [1.0, 1.0, 1.0, 1.0]);
image.rotate(glam::Vec2::new(200.0, 150.0), std::f32::consts::FRAC_PI_2);
let rotated = image.image_corners().unwrap();
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
}
fn line_samples(points: &[(f32, f32)]) -> Vec<StylusSample> {
points
.iter()
.enumerate()
.map(|(i, &(x, y))| StylusSample {
position: glam::Vec2::new(x, y),
pressure: 0.5,
timestamp: i as f64,
})
.collect()
}
#[test]
fn test_split_samples_at_circle() {
let c = glam::Vec2::new(50.0, 0.0);
// Lange Strecke mit nur zwei Punkten, Kreis in der Mitte: zwei Stücke, am Rand gekürzt
let pieces =
split_samples_at_circle(&line_samples(&[(0.0, 0.0), (100.0, 0.0)]), c, 10.0, 1.0)
.unwrap();
assert_eq!(pieces.len(), 2);
assert!((pieces[0].last().unwrap().position.x - 40.0).abs() < 1e-3);
assert!((pieces[1][0].position.x - 60.0).abs() < 1e-3);
// Druck und Zeit werden am Schnittpunkt interpoliert
assert!((pieces[0].last().unwrap().timestamp - 0.4).abs() < 1e-3);
// Viele Punkte, Kreis am Ende: ein Stück bleibt
let dense: Vec<(f32, f32)> = (0..=10).map(|i| (i as f32 * 10.0, 0.0)).collect();
let pieces = split_samples_at_circle(
&line_samples(&dense),
glam::Vec2::new(100.0, 0.0),
15.0,
1.0,
)
.unwrap();
assert_eq!(pieces.len(), 1);
assert!((pieces[0].last().unwrap().position.x - 85.0).abs() < 1e-3);
// Ganz im Kreis: nichts bleibt; Kreis weit weg: unberührt
assert_eq!(
split_samples_at_circle(&line_samples(&[(48.0, 0.0), (52.0, 0.0)]), c, 10.0, 1.0),
Some(Vec::new())
);
assert_eq!(
split_samples_at_circle(
&line_samples(&dense),
glam::Vec2::new(50.0, 40.0),
10.0,
1.0
),
None
);
// Winziger Rest wird verworfen
let pieces = split_samples_at_circle(
&line_samples(&[(0.0, 0.0), (100.0, 0.0)]),
glam::Vec2::new(5.0, 0.0),
4.0,
2.0,
)
.unwrap();
assert_eq!(pieces.len(), 1);
assert!((pieces[0][0].position.x - 9.0).abs() < 1e-3);
}
#[test]
fn test_partial_erase_splits_and_undoes_in_one_step() {
let mut notebook = Notebook::new();
let mut stroke = Stroke::new(
1,
line_samples(&[(100.0, 100.0), (500.0, 100.0)]),
Vec::new(),
[0.0, 0.0, 0.0, 1.0],
);
stroke.width = 5.0;
notebook.next_stroke_id = 10;
let page = notebook.current_page_mut();
page.add_stroke(stroke);
page.add_stroke(Stroke::new_image(
2,
"a.png".into(),
[
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(900.0, 0.0),
glam::Vec2::new(900.0, 900.0),
glam::Vec2::new(0.0, 900.0),
],
));
let mut shape = crate::geometry::shape_to_stroke(
crate::geometry::RecognizedShape::Line {
start: glam::Vec2::new(100.0, 300.0),
end: glam::Vec2::new(500.0, 300.0),
},
3,
[0.0, 0.0, 0.0, 1.0],
5.0,
);
shape.recompute_aabb();
page.add_stroke(shape);
let undo_depth = page.history.undo_stack.len();
// Zwei Schnitte desselben Zugs durch die Linie
assert!(notebook.erase_at(
glam::Vec2::new(200.0, 100.0),
20.0,
12.0,
EraserMode::Partial
));
assert!(notebook.erase_at(
glam::Vec2::new(400.0, 100.0),
20.0,
12.0,
EraserMode::Partial
));
// Form wird als Ganzes radiert
assert!(notebook.erase_at(
glam::Vec2::new(300.0, 300.0),
20.0,
12.0,
EraserMode::Partial
));
let page = notebook.current_page();
let ink: Vec<&Stroke> = page.strokes.iter().filter(|s| !s.is_image()).collect();
assert_eq!(ink.len(), 3, "Linie in drei Stücke zerlegt, Form entfernt");
assert!(ink.iter().all(|s| s.id >= 10 && !s.vertices.is_empty()));
assert!(page.strokes.iter().any(|s| s.id == 2), "Bild bleibt");
assert!(notebook.next_stroke_id > 10);
// Ein einziger Undo-Schritt stellt alles wieder her
let page = notebook.current_page_mut();
assert_eq!(page.history.undo_stack.len(), undo_depth + 1);
assert!(page.undo());
let mut ids: Vec<u64> = page.strokes.iter().map(|s| s.id).collect();
ids.sort();
assert_eq!(ids, vec![1, 2, 3]);
assert!(page.redo());
assert_eq!(page.strokes.iter().filter(|s| !s.is_image()).count(), 3);
// Danebenradieren ändert nichts
assert!(!notebook.erase_at(
glam::Vec2::new(800.0, 800.0),
20.0,
12.0,
EraserMode::Partial
));
}
#[test] #[test]
fn test_page_isolation() { fn test_page_isolation() {
let mut nb = Notebook::new(); let mut nb = Notebook::new();
+715 -75
View File
@@ -2,7 +2,7 @@
//! //!
//! Features: //! Features:
//! - Hochformat DIN A4 (210 mm x 297 mm) //! - Hochformat DIN A4 (210 mm x 297 mm)
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink") //! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links) //! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) //! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
//! - Zwei Export-Strategien: //! - Zwei Export-Strategien:
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder}; use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::assets::AssetStore;
use crate::background::{GRID_5MM_SPACING, LINED_SPACING}; use crate::background::{GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{ use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
@@ -78,6 +79,20 @@ pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
} }
} }
/// Kollisionsfreier Pfad `~/Documents/<name>.<ext>` (Verzeichnis wird angelegt).
pub fn resolve_export_path(name: &str, ext: &str) -> PathBuf {
let dir = default_documents_dir();
let _ = std::fs::create_dir_all(&dir);
let base = sanitize_filename_component(name);
let mut candidate = dir.join(format!("{base}.{ext}"));
let mut counter = 1;
while candidate.exists() {
candidate = dir.join(format!("{base}_{counter}.{ext}"));
counter += 1;
}
candidate
}
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR /// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig. /// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
pub fn resolve_pdf_export_path(title: &str) -> PathBuf { pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
@@ -109,15 +124,18 @@ pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
} }
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei. /// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
pub fn export_notebook_to_pdf<P: AsRef<Path>>( pub fn export_notebook_to_pdf<P: AsRef<Path>>(
notebook: &Notebook, notebook: &Notebook,
path: P, path: P,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?;
let file = File::create(path)?; let file = File::create(path)?;
let mut writer = BufWriter::new(file); let mut writer = BufWriter::new(file);
let doc = generate_pdf_document(notebook, strategy)?; std::io::Write::write_all(&mut writer, &bytes)?;
doc.save(&mut writer)?; std::io::Write::flush(&mut writer)?;
Ok(()) Ok(())
} }
@@ -125,40 +143,189 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
pub fn export_notebook_to_bytes( pub fn export_notebook_to_bytes(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<Vec<u8>, Box<dyn Error>> { ) -> Result<Vec<u8>, Box<dyn Error>> {
let doc = generate_pdf_document(notebook, strategy)?; let doc = generate_pdf_document(notebook, strategy, assets)?;
let bytes = doc.save_to_bytes()?; let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
Ok(bytes)
// Importierte PDF-Seiten als Vektorgrafik unterlegen
let underlays = pdf_underlays(notebook);
if underlays.is_empty() {
return Ok(bytes);
}
let sources: std::collections::HashMap<String, Vec<u8>> = underlays
.iter()
.filter_map(|u| Some((u.source.clone(), assets?.read_bytes(&u.source).ok()?)))
.collect();
let (merged, failed) = crate::pdf_merge::apply_underlays(&bytes, &underlays, &sources)?;
if failed.is_empty() {
return Ok(merged);
}
// Nicht übernehmbare Seiten (z. B. verschlüsselte PDFs) als Bild einbetten
let mut raster = std::collections::HashSet::new();
for (underlay, err) in &failed {
eprintln!(
"[PDF-Export] Seite {} des PDFs als Bild statt als Vektor: {err}",
underlay.source_page + 1
);
raster.insert(underlay.target_page);
}
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
let bytes = fix_text_encoding(doc.save_to_bytes()?, notebook)?;
let remaining: Vec<_> = underlays
.into_iter()
.filter(|u| !raster.contains(&u.target_page))
.collect();
if remaining.is_empty() {
return Ok(bytes);
}
let (merged, _) = crate::pdf_merge::apply_underlays(&bytes, &remaining, &sources)?;
Ok(merged)
}
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
let needs_fix = notebook
.pages
.iter()
.filter_map(|p| p.title.as_deref())
.any(|t| !t.is_ascii());
if !needs_fix {
return Ok(bytes);
}
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
for object in doc.objects.values_mut() {
let Ok(dict) = object.as_dict_mut() else {
continue;
};
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
if let Some(utf16) = utf16be_text(raw) {
*raw = utf16;
*format = printpdf::lopdf::StringFormat::Hexadecimal;
}
}
}
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
/// ungültigem UTF-8.
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
let text = std::str::from_utf8(raw).ok()?;
if text.is_ascii() {
return None;
}
let mut out = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
out.extend_from_slice(&unit.to_be_bytes());
}
Some(out)
}
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
notebook
.pages
.iter()
.enumerate()
.filter_map(|(i, page)| {
let pdf = page.pdf_background.as_ref()?;
let corners = pdf.corners();
let top_right = page_to_pdf_pt(corners[1]);
let bottom_left = page_to_pdf_pt(corners[3]);
let size = top_right - bottom_left;
Some(crate::pdf_merge::Underlay {
target_page: i,
source: pdf.asset.clone(),
source_page: pdf.page as usize,
rect: [bottom_left.x, bottom_left.y, size.x, size.y],
})
})
.collect()
} }
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs. /// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
pub fn generate_pdf_document( pub fn generate_pdf_document(
notebook: &Notebook, notebook: &Notebook,
strategy: ExportStrategy, strategy: ExportStrategy,
assets: Option<&AssetStore>,
) -> Result<PdfDocumentReference, Box<dyn Error>> {
generate_pdf_document_with(
notebook,
strategy,
assets,
&std::collections::HashSet::new(),
)
}
/// Wie [`generate_pdf_document`]; die PDF-Seiten der Seiten in `raster_pdf_pages` werden
/// gerendert und als Bild eingebettet.
fn generate_pdf_document_with(
notebook: &Notebook,
strategy: ExportStrategy,
assets: Option<&AssetStore>,
raster_pdf_pages: &std::collections::HashSet<usize>,
) -> Result<PdfDocumentReference, Box<dyn Error>> { ) -> Result<PdfDocumentReference, Box<dyn Error>> {
if notebook.pages.is_empty() { if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into()); return Err("Notizbuch enthält keine Seiten".into());
} }
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
if let Some(page) = notebook.pages.iter().find(|p| {
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
}) {
return Err(format!(
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
page.id
)
.into());
}
// 1. Erstes Dokument mit Seite 1 erstellen // 1. Erstes Dokument mit Seite 1 erstellen
let title = "ink-notes Notebook"; let title = "ink-notes Notebook";
let (doc, page1_idx, layer1_bg) = let (doc, page1_idx, layer1_bg) =
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
// Seitentitel werden zu Lesezeichen (Inhaltsverzeichnis im PDF-Betrachter)
if let Some(title) = &notebook.pages[0].title {
doc.add_bookmark(title.clone(), page1_idx);
}
// Seite 1 befüllen // Seite 1 befüllen
let page1_ref = doc.get_page(page1_idx); let page1_ref = doc.get_page(page1_idx);
let layer1_bg_ref = page1_ref.get_layer(layer1_bg); let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
render_background_layer(&layer1_bg_ref, notebook.pages[0].background); render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
if raster_pdf_pages.contains(&0) {
render_pdf_raster(&page1_ref.add_layer("PDF"), &notebook.pages[0], assets);
}
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
render_image_layer(&page1_ref.add_layer("Images"), &notebook.pages[0], assets);
}
let layer1_ink_ref = page1_ref.add_layer("Ink"); let layer1_ink_ref = page1_ref.add_layer("Ink");
render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy); render_ink_layer(&layer1_ink_ref, &notebook.pages[0], strategy);
// 2. Alle nachfolgenden Seiten hinzufügen // 2. Alle nachfolgenden Seiten hinzufügen
for page in &notebook.pages[1..] { for (i, page) in notebook.pages.iter().enumerate().skip(1) {
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background"); let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
if let Some(title) = &page.title {
doc.add_bookmark(title.clone(), page_idx);
}
let page_ref = doc.get_page(page_idx); let page_ref = doc.get_page(page_idx);
let layer_bg_ref = page_ref.get_layer(layer_bg); let layer_bg_ref = page_ref.get_layer(layer_bg);
render_background_layer(&layer_bg_ref, page.background); render_background_layer(&layer_bg_ref, page.background);
if raster_pdf_pages.contains(&i) {
render_pdf_raster(&page_ref.add_layer("PDF"), page, assets);
}
if page.strokes.iter().any(Stroke::is_image) {
render_image_layer(&page_ref.add_layer("Images"), page, assets);
}
let layer_ink_ref = page_ref.add_layer("Ink"); let layer_ink_ref = page_ref.add_layer("Ink");
render_ink_layer(&layer_ink_ref, page, strategy); render_ink_layer(&layer_ink_ref, page, strategy);
@@ -258,6 +425,9 @@ fn render_background_layer(layer: &printpdf::PdfLayerReference, background: Page
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). /// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
for stroke in &page.strokes { for stroke in &page.strokes {
if stroke.is_image() {
continue;
}
if let Some(ref shape) = stroke.shape { if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color); render_recognized_shape(layer, shape, stroke.color);
} else { } else {
@@ -273,6 +443,210 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
} }
} }
/// Auflösung, mit der Bilder höchstens eingebettet werden (Druckqualität).
pub const PDF_IMAGE_DPI: f32 = 300.0;
/// Seitenkoordinate in PDF-Punkten (ohne Begrenzung auf das Blatt, Ursprung unten links).
fn page_to_pdf_pt(p: glam::Vec2) -> glam::Vec2 {
const PT_PER_MM: f32 = 72.0 / 25.4;
glam::Vec2::new(
p.x / PAGE_WIDTH * A4_WIDTH_MM * PT_PER_MM,
(1.0 - p.y / PAGE_HEIGHT) * A4_HEIGHT_MM * PT_PER_MM,
)
}
/// PDF-Transformationsmatrix, die das Einheitsquadrat eines Bildes (Ursprung unten links)
/// auf die Bildecken oben links, oben rechts, unten rechts, unten links abbildet.
pub fn image_matrix(corners: &[glam::Vec2; 4]) -> [f32; 6] {
let [tl, _, br, bl] = corners.map(page_to_pdf_pt);
let x_axis = br - bl;
let y_axis = tl - bl;
[x_axis.x, x_axis.y, y_axis.x, y_axis.y, bl.x, bl.y]
}
/// Anzahl der Bildkomponenten eines JPEG laut Frame-Header (1 = Graustufen, 3 = Farbe,
/// 4 = CMYK), `None` bei unlesbarem Header.
fn jpeg_components(bytes: &[u8]) -> Option<u8> {
let mut i = 2;
while i + 9 < bytes.len() {
if bytes[i] != 0xFF {
return None;
}
let marker = bytes[i + 1];
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
// SOF0–SOF15 außer DHT (C4), JPG (C8) und DAC (CC)
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
return bytes.get(i + 9).copied();
}
i += 2 + len;
}
None
}
/// Bilddaten für das PDF: JPEG-Dateien werden unverändert eingebettet, alles andere als
/// RGB auf weißem Grund (Transparenz wird mit dem Papier verrechnet), höchstens mit
/// `PDF_IMAGE_DPI` bezogen auf die Größe auf der Seite.
fn pdf_image_xobject(
assets: &AssetStore,
asset: &str,
corners: &[glam::Vec2; 4],
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
let bytes = assets.read_bytes(asset)?;
let decoded = image::load_from_memory(&bytes)?;
if asset.ends_with(".jpg") {
let color_space = match jpeg_components(&bytes) {
Some(1) => Some(ColorSpace::Greyscale),
Some(3) => Some(ColorSpace::Rgb),
_ => None,
};
if let Some(color_space) = color_space {
return Ok(ImageXObject {
width: Px(decoded.width() as usize),
height: Px(decoded.height() as usize),
color_space,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: bytes,
image_filter: Some(ImageFilter::DCT),
smask: None,
clipping_bbox: None,
});
}
}
// Größe auf der Seite in Zoll → benötigte Pixel
let width_in = (corners[1] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let height_in = (corners[3] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
let max_w = ((width_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let max_h = ((height_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
let decoded = if decoded.width() > max_w || decoded.height() > max_h {
decoded.resize(max_w, max_h, image::imageops::FilterType::Triangle)
} else {
decoded
};
let rgba = decoded.into_rgba8();
let mut rgb = Vec::with_capacity((rgba.width() * rgba.height() * 3) as usize);
for px in rgba.pixels() {
let alpha = px[3] as u32;
for c in &px.0[..3] {
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
}
}
Ok(rgb_xobject(rgba.width(), rgba.height(), rgb))
}
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
fn render_image_layer(
layer: &printpdf::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
for stroke in &page.strokes {
let (Some(asset), Some(corners)) = (stroke.image.as_deref(), stroke.image_corners()) else {
continue;
};
let xobject = match assets.map(|a| pdf_image_xobject(a, asset, &corners)) {
Some(Ok(xobject)) => xobject,
other => {
if let Some(Err(err)) = other {
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
}
placeholder_xobject()
}
};
draw_image_xobject(layer, xobject, &corners);
}
}
/// Graue 1×1-Fläche für fehlende Bilder.
fn placeholder_xobject() -> printpdf::ImageXObject {
rgb_xobject(1, 1, vec![200, 200, 200])
}
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> printpdf::ImageXObject {
printpdf::ImageXObject {
width: printpdf::Px(width as usize),
height: printpdf::Px(height as usize),
color_space: printpdf::ColorSpace::Rgb,
bits_per_component: printpdf::ColorBits::Bit8,
interpolate: true,
image_data: rgb,
image_filter: None,
smask: None,
clipping_bbox: None,
}
}
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
/// unten links ausfüllt.
fn draw_image_xobject(
layer: &printpdf::PdfLayerReference,
xobject: printpdf::ImageXObject,
corners: &[glam::Vec2; 4],
) {
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
layer.save_graphics_state();
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners)));
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
printpdf::Image::from(xobject).add_to_layer(
layer.clone(),
printpdf::ImageTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / width),
scale_y: Some(1.0 / height),
..Default::default()
},
);
layer.restore_graphics_state();
}
/// Auflösung, mit der PDF-Seiten gerendert werden, die nicht als Vektor übernommen
/// werden können.
const PDF_RASTER_DPI: f32 = 200.0;
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
fn render_pdf_raster(
layer: &printpdf::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
let Some(pdf) = &page.pdf_background else {
return;
};
let longest_pt = pdf.width_pt.max(pdf.height_pt);
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
let rendered = assets
.ok_or_else(|| "kein Asset-Ordner".to_string())
.and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string()))
.and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels));
let xobject = match rendered {
Ok(image) => {
let rgb = image
.pixels
.as_chunks::<4>()
.0
.iter()
.flat_map(|px| [px[0], px[1], px[2]])
.collect();
rgb_xobject(image.width, image.height, rgb)
}
Err(err) => {
eprintln!(
"[PDF-Export] PDF-Seite {} nicht darstellbar: {err}",
pdf.page + 1
);
placeholder_xobject()
}
};
draw_image_xobject(layer, xobject, &pdf.corners());
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF. /// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape( fn render_recognized_shape(
@@ -319,6 +693,59 @@ fn render_recognized_shape(
layer.add_polygon(poly); layer.add_polygon(poly);
} }
RecognizedShape::Ellipse {
center,
radius_x,
radius_y,
rotation,
} => {
let ring = (0..72)
.map(|i| {
let t = i as f32 / 72.0 * std::f32::consts::TAU;
let p =
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
})
.collect();
layer.add_polygon(Polygon {
rings: vec![ring],
mode: PaintMode::Stroke,
winding_order: WindingOrder::NonZero,
});
}
RecognizedShape::Arrow { start, end, double } => {
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let shaft_len = (*end - *start).length();
let dir = (*end - *start).normalize_or_zero();
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let start_head =
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
let line_start = if *double { start_head.0 } else { *start };
let to_pdf = |p: glam::Vec2| {
let (x, y) = page_to_pdf_mm(p.x, p.y);
(Point::new(Mm(x), Mm(y)), false)
};
layer.add_line(Line {
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
is_closed: false,
});
layer.set_fill_color(stroke_color);
let mut heads = vec![(*end, end_head)];
if *double {
heads.push((*start, start_head));
}
for (tip, (_, left, right)) in heads {
layer.add_polygon(Polygon {
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
}
}
RecognizedShape::Rectangle { corners } => { RecognizedShape::Rectangle { corners } => {
let mut ring = Vec::with_capacity(4); let mut ring = Vec::with_capacity(4);
for c in corners { for c in corners {
@@ -402,76 +829,58 @@ fn render_recognized_shape(
y_axis_end, y_axis_end,
with_arrows, with_arrows,
} => { } => {
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let y_arrow = crate::geometry::arrow_head(
*y_axis_end,
y_dir,
PDF_SHAPE_HALF_WIDTH,
(*y_axis_end - *origin).length(),
);
let x_arrow = crate::geometry::arrow_head(
*x_axis_end,
x_dir,
PDF_SHAPE_HALF_WIDTH,
(*x_axis_end - *origin).length(),
);
let (y_line_end, x_line_end) = if *with_arrows {
(y_arrow.0, x_arrow.0)
} else {
(*y_axis_end, *x_axis_end)
};
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); for end in [y_line_end, x_line_end] {
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); let (ex, ey) = page_to_pdf_mm(end.x, end.y);
layer.add_line(Line {
// Achse 1 (Y) points: vec![
let line_y = Line { (Point::new(Mm(ox), Mm(oy)), false),
points: vec![ (Point::new(Mm(ex), Mm(ey)), false),
(Point::new(Mm(ox), Mm(oy)), false), ],
(Point::new(Mm(yx), Mm(yy)), false), is_closed: false,
], });
is_closed: false, }
};
layer.add_line(line_y);
// Achse 2 (X)
let line_x = Line {
points: vec![
(Point::new(Mm(ox), Mm(oy)), false),
(Point::new(Mm(xx), Mm(xy)), false),
],
is_closed: false,
};
layer.add_line(line_x);
// Pfeilspitzen als gefüllte Dreiecke // Pfeilspitzen als gefüllte Dreiecke
if *with_arrows { if *with_arrows {
layer.set_fill_color(stroke_color); layer.set_fill_color(stroke_color);
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
let y_dir = (*y_axis_end - *origin).normalize_or_zero(); let ring = [tip, left, right]
let arrow_len_page = 25.0; .iter()
let arrow_w_page = 16.0; .map(|p| {
let (px, py) = page_to_pdf_mm(p.x, p.y);
let base_y = *y_axis_end - y_dir * arrow_len_page; (Point::new(Mm(px), Mm(py)), false)
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); })
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); .collect();
layer.add_polygon(Polygon {
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); rings: vec![ring],
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y); mode: PaintMode::Fill,
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y); winding_order: WindingOrder::NonZero,
});
let arrow_poly_y = Polygon { }
rings: vec![vec![
(Point::new(Mm(tx), Mm(ty)), false),
(Point::new(Mm(lx), Mm(ly)), false),
(Point::new(Mm(rx), Mm(ry)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_y);
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
let base_x = *x_axis_end - x_dir * arrow_len_page;
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
let arrow_poly_x = Polygon {
rings: vec![vec![
(Point::new(Mm(tx_x), Mm(ty_x)), false),
(Point::new(Mm(lx_x), Mm(ly_x)), false),
(Point::new(Mm(rx_x), Mm(ry_x)), false),
]],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
};
layer.add_polygon(arrow_poly_x);
} }
} }
} }
@@ -749,7 +1158,7 @@ mod tests {
let pdf_path = temp_dir.join("ink_notes_test_export.pdf"); let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
let export_res = let export_res =
export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon); export_notebook_to_pdf(&notebook, &pdf_path, ExportStrategy::FilledPolygon, None);
assert!( assert!(
export_res.is_ok(), export_res.is_ok(),
"PDF-Export zu Datei muss erfolgreich sein" "PDF-Export zu Datei muss erfolgreich sein"
@@ -775,7 +1184,7 @@ mod tests {
); );
// 3. Export als Bytes testen (Strategie B: BezierCurve) // 3. Export als Bytes testen (Strategie B: BezierCurve)
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve); let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::BezierCurve, None);
assert!(bytes_res.is_ok()); assert!(bytes_res.is_ok());
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
@@ -828,6 +1237,27 @@ mod tests {
); );
page.add_stroke(coords_stroke); page.add_stroke(coords_stroke);
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
crate::geometry::RecognizedShape::Ellipse {
center: glam::Vec2::new(1400.0, 600.0),
radius_x: 300.0,
radius_y: 150.0,
rotation: 0.4,
},
);
page.add_stroke(ellipse_stroke);
for (id, double) in [(41, false), (42, true)] {
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
crate::geometry::RecognizedShape::Arrow {
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
double,
},
);
page.add_stroke(arrow_stroke);
}
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape( let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
crate::geometry::RecognizedShape::Triangle { crate::geometry::RecognizedShape::Triangle {
corners: [ corners: [
@@ -877,13 +1307,223 @@ mod tests {
); );
page.add_stroke(octahedron_stroke); page.add_stroke(octahedron_stroke);
let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon); let bytes_res = export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None);
assert!(bytes_res.is_ok(), "Shape export must succeed"); assert!(bytes_res.is_ok(), "Shape export must succeed");
let bytes = bytes_res.unwrap(); let bytes = bytes_res.unwrap();
assert!(bytes.len() > 1024); assert!(bytes.len() > 1024);
assert!(bytes.starts_with(b"%PDF-1.")); assert!(bytes.starts_with(b"%PDF-1."));
} }
#[test]
fn test_image_matrix_maps_unit_square_to_corners() {
// Bild über die volle Seitenbreite, oberes Viertel der Seite
let corners = [
glam::Vec2::new(0.0, 0.0),
glam::Vec2::new(PAGE_WIDTH, 0.0),
glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT / 4.0),
glam::Vec2::new(0.0, PAGE_HEIGHT / 4.0),
];
let [a, b, c, d, e, f] = image_matrix(&corners);
let page_w_pt = A4_WIDTH_MM * 72.0 / 25.4;
let page_h_pt = A4_HEIGHT_MM * 72.0 / 25.4;
let map = |u: f32, v: f32| (a * u + c * v + e, b * u + d * v + f);
let close = |(x, y): (f32, f32), (ex, ey): (f32, f32)| {
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01
};
// Unten links des Bildes, oben rechts des Bildes (PDF-Ursprung unten links)
assert!(close(map(0.0, 0.0), (0.0, page_h_pt * 0.75)));
assert!(close(map(1.0, 1.0), (page_w_pt, page_h_pt)));
// Gedrehtes Bild: Achsen folgen den Ecken
let rotated = [
glam::Vec2::new(500.0, 500.0),
glam::Vec2::new(500.0, 900.0),
glam::Vec2::new(300.0, 900.0),
glam::Vec2::new(300.0, 500.0),
];
let m = image_matrix(&rotated);
assert!(
m[0].abs() < 1e-3 && m[1] < 0.0,
"x-Achse zeigt nach unten: {m:?}"
);
}
#[test]
fn test_pdf_export_embeds_images() {
let dir = tempfile::tempdir().unwrap();
let assets = AssetStore::new(dir.path());
let jpg = crate::assets::tests::sample_image_bytes(64, 48, image::ImageFormat::Jpeg);
let png = crate::assets::tests::sample_image_bytes(30, 20, image::ImageFormat::Png);
let jpg_asset = assets.import_bytes(&jpg).unwrap().asset;
let png_asset = assets.import_bytes(&png).unwrap().asset;
let mut notebook = Notebook::new();
let page = notebook.current_page_mut();
for (id, asset) in [
(1, jpg_asset),
(2, png_asset),
(3, format!("{}.png", "0".repeat(64))),
] {
let y = id as f32 * 400.0;
page.strokes.push(Stroke::new_image(
id,
asset,
[
glam::Vec2::new(100.0, y),
glam::Vec2::new(700.0, y),
glam::Vec2::new(700.0, y + 300.0),
glam::Vec2::new(100.0, y + 300.0),
],
));
}
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets))
.expect("Export mit Bildern");
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(
contains(b"/DCTDecode"),
"JPEG wird nicht unverändert eingebettet"
);
// JPEG-Daten liegen byte-identisch im PDF
assert!(contains(&jpg[..64]));
assert!(
contains(b"/Images") || contains(b"(Images)"),
"Bild-Ebene fehlt"
);
let image_objects = bytes
.windows(b"/Subtype/Image".len())
.filter(|w| *w == b"/Subtype/Image")
.count()
+ bytes
.windows(b"/Subtype /Image".len())
.filter(|w| *w == b"/Subtype /Image")
.count();
// JPEG, PNG und Ersatzfläche für die fehlende Datei
assert_eq!(image_objects, 3);
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_pdf_export_underlays_imported_pages() {
let temp_dir = tempfile::tempdir().unwrap();
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 90)]);
let (mut notebook, _, dir) =
crate::storage::create_notebook_from_pdf(temp_dir.path(), "Folien", source).unwrap();
crate::storage::load_all_pages(&dir, &mut notebook).unwrap();
let assets = AssetStore::new(&dir);
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, Some(&assets))
.unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
assert_eq!(doc.get_pages().len(), 2);
for page_id in doc.get_pages().into_values() {
let content = doc.get_page_content(page_id).unwrap();
assert!(
String::from_utf8_lossy(&content).starts_with("q "),
"Originalseite fehlt als Unterlage"
);
}
// Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links
let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap();
let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap();
let px = |x: u32, y: u32| {
let i = ((y * image.width + x) * 4) as usize;
image.pixels[i..i + 3].to_vec()
};
assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links");
assert_eq!(
px(200, 500),
vec![255, 255, 255],
"unter der Folie ist Platz"
);
// Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt
// dort oben rechts, in der Quelle wie im Export
let source_pdf = hayro::hayro_syntax::Pdf::new(
assets
.read_bytes(&notebook.pages[1].pdf_background.as_ref().unwrap().asset)
.unwrap(),
)
.unwrap();
let shown = crate::pdf_doc::render_page(&source_pdf, 1, 600).unwrap();
let red_at = |img: &crate::assets::RgbaImage, x: u32, y: u32| {
let i = ((y * img.width + x) * 4) as usize;
img.pixels[i..i + 3] == [255, 0, 0]
};
assert!(red_at(&shown, shown.width - 5, 5));
let exported = crate::pdf_doc::render_page(&pdf, 1, 600).unwrap();
assert!(red_at(&exported, exported.width - 5, 5));
assert!(!red_at(&exported, 5, 5));
// Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche)
assert!(export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).is_ok());
}
#[test]
fn test_page_titles_become_pdf_bookmarks() {
let mut notebook = Notebook::new();
notebook.add_page(PageBackground::Blank);
notebook.add_page(PageBackground::Blank);
let ids: Vec<u64> = notebook.pages.iter().map(|p| p.id).collect();
notebook.set_page_title(ids[0], "Einleitung");
notebook.set_page_title(ids[2], "Übung 2 – Integrale");
let bytes =
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, None).unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
let catalog = doc.catalog().unwrap();
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
// Titel als PDF-Textstrings: ASCII unverändert, Umlaute als UTF-16BE mit BOM
let titles: Vec<String> = doc
.objects
.values()
.filter_map(|o| o.as_dict().ok())
.filter(|d| d.has(b"Parent"))
.filter_map(|d| match d.get(b"Title") {
Ok(printpdf::lopdf::Object::String(raw, _)) => {
Some(if raw.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> = raw[2..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&units).unwrap()
} else {
String::from_utf8(raw.clone()).unwrap()
})
}
_ => None,
})
.collect();
assert!(titles.contains(&"Einleitung".to_string()), "{titles:?}");
assert!(
titles.contains(&"Übung 2 – Integrale".to_string()),
"{titles:?}"
);
}
#[test]
fn test_jpeg_component_count() {
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
assert_eq!(jpeg_components(&color), Some(3));
let gray = {
let img = image::GrayImage::from_pixel(8, 8, image::Luma([90]));
let mut out = Vec::new();
image::DynamicImage::ImageLuma8(img)
.write_to(
&mut std::io::Cursor::new(&mut out),
image::ImageFormat::Jpeg,
)
.unwrap();
out
};
assert_eq!(jpeg_components(&gray), Some(1));
assert_eq!(jpeg_components(b"\xFF\xD8kaputt"), None);
}
#[test] #[test]
fn test_sanitize_filename_component() { fn test_sanitize_filename_component() {
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1"); assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
+248
View File
@@ -0,0 +1,248 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
//!
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
use std::path::{Path, PathBuf};
use crate::assets::is_supported_image_path;
/// Ein Eintrag der Dateiliste.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PickerEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
}
/// Welche Dateien die Auswahl anbietet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerKind {
/// Bilder zum Einfügen auf der aktuellen Seite
Image,
/// PDF zum Importieren als neues Notizbuch
Pdf,
}
impl PickerKind {
/// Passt die Datei zu dieser Auswahl?
pub fn accepts(self, path: &Path) -> bool {
match self {
PickerKind::Image => is_supported_image_path(path),
PickerKind::Pdf => is_pdf_path(path),
}
}
}
/// Ist die Datei dem Namen nach ein PDF?
pub fn is_pdf_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
}
/// Zustand der Dateiauswahl.
#[derive(Debug, Clone, PartialEq)]
pub struct FilePicker {
pub kind: PickerKind,
pub dir: PathBuf,
pub entries: Vec<PickerEntry>,
/// Ordner nicht lesbar
pub error: Option<String>,
}
impl FilePicker {
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner (Bilder) bzw. Downloads (PDF),
/// sonst im Home-Ordner.
pub fn open(kind: PickerKind, dir: Option<&Path>) -> Self {
let start = dir
.filter(|d| d.is_dir())
.map(Path::to_path_buf)
.or_else(|| {
let preferred = match kind {
PickerKind::Image => "🖼 Bilder",
PickerKind::Pdf => "⬇ Downloads",
};
let all = shortcuts();
all.iter()
.find(|(label, _)| *label == preferred)
.or_else(|| all.last())
.map(|(_, p)| p.clone())
})
.unwrap_or_else(|| PathBuf::from("/"));
let mut picker = Self {
kind,
dir: start.clone(),
entries: Vec::new(),
error: None,
};
picker.navigate(&start);
picker
}
/// Wechselt in `dir` und liest den Inhalt neu ein.
pub fn navigate(&mut self, dir: &Path) {
match list_dir(dir, self.kind) {
Ok(entries) => {
self.dir = dir.to_path_buf();
self.entries = entries;
self.error = None;
}
Err(err) => {
self.error = Some(format!(
"'{}' kann nicht geöffnet werden: {err}",
dir.display()
));
}
}
}
/// Eine Ebene nach oben.
pub fn go_up(&mut self) {
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
self.navigate(&parent);
}
}
}
/// Ordner und passende Dateien in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry>> {
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
return None;
}
let path = entry.path();
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
let is_dir = path.is_dir();
(is_dir || kind.accepts(&path)).then_some(PickerEntry { name, path, is_dir })
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let user_dirs =
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
parse_user_dir(&user_dirs, key, &home)
.into_iter()
.chain(fallbacks.iter().map(|f| home.join(f)))
.find(|p| p.is_dir() && *p != home)
};
let mut list = Vec::new();
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
list.push(("🖼 Bilder", p));
}
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
list.push(("⬇ Downloads", p));
}
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
list.push(("🖥 Schreibtisch", p));
}
list.push(("🏠 Persönlicher Ordner", home));
list
}
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
content.lines().find_map(|line| {
let value = line
.trim()
.strip_prefix(key)?
.trim_start()
.strip_prefix('=')?;
let value = value.trim().trim_matches('"');
let path = match value.strip_prefix("$HOME") {
Some(rest) => home.join(rest.trim_start_matches('/')),
None => PathBuf::from(value),
};
path.is_absolute().then_some(path)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_list_dir_sorts_and_filters() {
let dir = tempfile::tempdir().unwrap();
for name in [
"b.png",
"A.jpg",
"notiz.txt",
".versteckt.png",
"c.webp",
"Skript.PDF",
] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
std::fs::create_dir(dir.path().join(".cache")).unwrap();
let names = |kind| -> Vec<String> {
list_dir(dir.path(), kind)
.unwrap()
.into_iter()
.map(|e| e.name)
.collect()
};
assert_eq!(
names(PickerKind::Image),
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
);
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
}
#[test]
fn test_navigation_and_errors() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("Fotos");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
let mut picker = FilePicker::open(PickerKind::Image, Some(&sub));
assert_eq!(picker.dir, sub);
assert_eq!(picker.entries.len(), 1);
picker.go_up();
assert_eq!(picker.dir, dir.path());
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
picker.navigate(&dir.path().join("gibt-es-nicht"));
assert!(picker.error.is_some());
assert_eq!(picker.dir, dir.path());
}
#[test]
fn test_parse_user_dirs() {
let home = Path::new("/home/anna");
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
assert_eq!(
parse_user_dir(content, "XDG_PICTURES_DIR", home),
Some(PathBuf::from("/home/anna/Bilder"))
);
assert_eq!(
parse_user_dir(content, "XDG_MUSIC_DIR", home),
Some(PathBuf::from("/daten/Musik"))
);
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
}
}
+1376 -145
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -18,6 +18,13 @@ pub enum HistoryAction {
before: Vec<Stroke>, before: Vec<Stroke>,
after: Vec<Stroke>, after: Vec<Stroke>,
}, },
/// Teilradierer: Striche wurden durch ihre übrig gebliebenen Stücke ersetzt.
SplitStrokes {
removed: Vec<Stroke>,
added: Vec<Stroke>,
/// Zeitpunkt des letzten Schnitts (zum Zusammenfassen eines Radierzugs)
at: std::time::Instant,
},
} }
/// History-Stack für Transaktionsverwaltung (Undo / Redo). /// History-Stack für Transaktionsverwaltung (Undo / Redo).
@@ -41,6 +48,40 @@ impl HistoryStack {
self.redo_stack.clear(); self.redo_stack.clear();
} }
/// Registriert einen Teilradier-Schnitt. Folgt er innerhalb von
/// [`crate::document::PARTIAL_ERASE_MERGE_WINDOW`] auf einen vorherigen, werden beide zu
/// einem Undo-Schritt zusammengefasst: Stücke, die im selben Zug wieder zerschnitten
/// wurden, verschwinden aus der Bilanz.
pub fn push_split(&mut self, removed: Vec<Stroke>, added: Vec<Stroke>) {
let now = std::time::Instant::now();
if let Some(HistoryAction::SplitStrokes {
removed: prev_removed,
added: prev_added,
at,
}) = self.undo_stack.last_mut()
{
if now.duration_since(*at) <= crate::document::PARTIAL_ERASE_MERGE_WINDOW
&& self.redo_stack.is_empty()
{
for stroke in removed {
if let Some(pos) = prev_added.iter().position(|s| s.id == stroke.id) {
prev_added.remove(pos);
} else {
prev_removed.push(stroke);
}
}
prev_added.extend(added);
*at = now;
return;
}
}
self.push(HistoryAction::SplitStrokes {
removed,
added,
at: now,
});
}
/// Gibt zurück, ob Undo verfügbar ist. /// Gibt zurück, ob Undo verfügbar ist.
pub fn can_undo(&self) -> bool { pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty() !self.undo_stack.is_empty()
@@ -95,6 +136,12 @@ impl HistoryStack {
self.redo_stack self.redo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !added.iter().any(|a| a.id == s.id));
strokes.extend(removed.iter().cloned());
self.redo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
} }
true true
@@ -140,6 +187,12 @@ impl HistoryStack {
self.undo_stack self.undo_stack
.push(HistoryAction::ModifyStrokes { before, after }); .push(HistoryAction::ModifyStrokes { before, after });
} }
HistoryAction::SplitStrokes { removed, added, at } => {
strokes.retain(|s| !removed.iter().any(|r| r.id == s.id));
strokes.extend(added.iter().cloned());
self.undo_stack
.push(HistoryAction::SplitStrokes { removed, added, at });
}
} }
true true
+392
View File
@@ -0,0 +1,392 @@
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
//! Papier und vor der Tinte.
//!
//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte
//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim
//! Herauszoomen nicht flimmern.
use std::collections::{HashMap, HashSet};
use crate::assets::RgbaImage;
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
#[repr(C)]
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ImageVertex {
pub position: [f32; 2],
pub uv: [f32; 2],
}
impl ImageVertex {
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBUTES,
}
}
}
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
let v = |i: usize, uv: [f32; 2]| ImageVertex {
position: corners[i].to_array(),
uv,
};
let (tl, tr, br, bl) = (
v(0, [0.0, 0.0]),
v(1, [1.0, 0.0]),
v(2, [1.0, 1.0]),
v(3, [0.0, 1.0]),
);
[tl, tr, bl, bl, tr, br]
}
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
let mut levels = vec![base.clone()];
let Some(mut current) =
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
else {
return levels;
};
while current.width() > 1 || current.height() > 1 {
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
current = image::imageops::resize(&current, w, h, image::imageops::FilterType::Triangle);
levels.push(RgbaImage {
width: w,
height: h,
pixels: current.as_raw().clone(),
});
}
levels
}
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
const MISSING: &str = "";
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben.
const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
/// Antwort einer Texturquelle für einen Schlüssel.
pub enum TextureSource {
Ready(RgbaImage),
/// Wird gerade erzeugt; später erneut fragen
Pending,
Failed(String),
}
pub struct ImageLayer {
pipeline: wgpu::RenderPipeline,
texture_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
textures: HashMap<String, (wgpu::BindGroup, u64)>,
texture_bytes: u64,
/// Bilder der aktuellen Seite: Asset-Name und Ecken
quads: Vec<(String, [glam::Vec2; 4])>,
vertex_buffer: Option<wgpu::Buffer>,
vertices_dirty: bool,
}
impl ImageLayer {
pub fn new(
device: &wgpu::Device,
shader: &wgpu::ShaderModule,
camera_layout: &wgpu::BindGroupLayout,
format: wgpu::TextureFormat,
) -> Self {
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Texture Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Image Pipeline Layout"),
bind_group_layouts: &[camera_layout, &texture_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Image Render Pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: "vs_image",
buffers: &[ImageVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: "fs_image",
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Image Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
pipeline,
texture_layout,
sampler,
textures: HashMap::new(),
texture_bytes: 0,
quads: Vec::new(),
vertex_buffer: None,
vertices_dirty: false,
}
}
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
if quads != self.quads {
self.quads = quads;
self.vertices_dirty = true;
}
}
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
/// ohne Änderungen kostet das nichts.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
load: &mut dyn FnMut(&str) -> TextureSource,
) {
let missing: Vec<String> = self
.quads
.iter()
.map(|(key, _)| key)
.filter(|key| !self.textures.contains_key(*key))
.cloned()
.collect();
for key in missing {
if self.textures.contains_key(&key) {
continue;
}
let (bind_group, bytes) = match load(&key) {
TextureSource::Ready(image) => (
self.upload_texture(device, queue, &key, &image),
image.pixels.len() as u64 * 4 / 3,
),
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
TextureSource::Pending => continue,
TextureSource::Failed(err) => {
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}");
(self.missing_texture(device, queue), 4)
}
};
self.texture_bytes += bytes;
self.textures.insert(key, (bind_group, bytes));
}
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
if self.texture_bytes > TEXTURE_CACHE_BYTES {
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
self.textures.retain(|name, _| used.contains(name));
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
}
if !self.vertices_dirty {
return;
}
self.vertices_dirty = false;
let vertices: Vec<ImageVertex> = self
.quads
.iter()
.flat_map(|(_, corners)| quad_vertices(corners))
.collect();
if vertices.is_empty() {
return;
}
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
let too_small = self
.vertex_buffer
.as_ref()
.is_none_or(|b| b.size() < bytes.len() as u64);
if too_small {
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Image Vertex Buffer"),
size: (bytes.len() as u64 * 2).max(1024),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
if let Some(buffer) = &self.vertex_buffer {
queue.write_buffer(buffer, 0, bytes);
}
}
/// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)?
pub fn is_waiting(&self) -> bool {
self.quads
.iter()
.any(|(key, _)| !self.textures.contains_key(key))
}
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
let Some(buffer) = &self.vertex_buffer else {
return;
};
if self.quads.is_empty() || self.vertices_dirty {
return;
}
render_pass.set_pipeline(&self.pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for (i, (asset, _)) in self.quads.iter().enumerate() {
let Some((bind_group, _)) = self.textures.get(asset) else {
continue;
};
render_pass.set_bind_group(1, bind_group, &[]);
let start = (i * 6) as u32;
render_pass.draw(start..start + 6, 0..1);
}
}
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
// Hellgraue Fläche statt eines unsichtbaren Bildes
let gray = RgbaImage {
width: 1,
height: 1,
pixels: vec![200, 200, 200, 255],
};
self.upload_texture(device, queue, MISSING, &gray)
}
fn upload_texture(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
image: &RgbaImage,
) -> wgpu::BindGroup {
let levels = mip_chain(image);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: levels.len() as u32,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
for (level, mip) in levels.iter().enumerate() {
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &texture,
mip_level: level as u32,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&mip.pixels,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(4 * mip.width),
rows_per_image: Some(mip.height),
},
wgpu::Extent3d {
width: mip.width,
height: mip.height,
depth_or_array_layers: 1,
},
);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Image Texture Bind Group"),
layout: &self.texture_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quad_vertices_map_corners_to_texture_corners() {
let corners = [
glam::Vec2::new(10.0, 10.0),
glam::Vec2::new(50.0, 10.0),
glam::Vec2::new(50.0, 30.0),
glam::Vec2::new(10.0, 30.0),
];
let quad = quad_vertices(&corners);
for v in quad {
// Texturkoordinate entspricht der relativen Lage im Rechteck
let u = (v.position[0] - 10.0) / 40.0;
let w = (v.position[1] - 10.0) / 20.0;
assert_eq!(v.uv, [u, w]);
}
// Beide Dreiecke decken alle vier Ecken ab
for c in corners {
assert!(quad.iter().any(|v| v.position == c.to_array()));
}
}
#[test]
fn test_mip_chain_halves_down_to_one_pixel() {
let base = RgbaImage {
width: 8,
height: 3,
pixels: vec![128; 8 * 3 * 4],
};
let levels = mip_chain(&base);
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
for l in &levels {
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
}
}
}
+8
View File
@@ -1,14 +1,22 @@
//! ink-notes: Latenzoptimierte Inking-App für Linux. //! ink-notes: Latenzoptimierte Inking-App für Linux.
pub mod assets;
pub mod autosave; pub mod autosave;
pub mod background; pub mod background;
pub mod canvas; pub mod canvas;
pub mod document; pub mod document;
pub mod export; pub mod export;
pub mod file_picker;
pub mod filter; pub mod filter;
pub mod geometry; pub mod geometry;
pub mod history; pub mod history;
pub mod image_layer;
pub mod page_export;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod platform;
pub mod selection; pub mod selection;
pub mod settings;
pub mod spline; pub mod spline;
pub mod storage; pub mod storage;
pub mod ui; pub mod ui;
+1001 -240
View File
File diff suppressed because it is too large. Load diff
+233
View File
@@ -0,0 +1,233 @@
//! Export einzelner Seiten als PNG oder SVG und einer Auswahl als Bild.
//!
//! Grundlage ist immer der PDF-Export: die Seite wird als einseitiges PDF erzeugt (mit
//! Bildern, importierten PDF-Seiten und Formen) und anschließend mit `hayro` gerastert bzw.
//! mit `hayro-svg` nach SVG umgewandelt. So sehen PDF, PNG und SVG gleich aus.
use std::error::Error;
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::{TRANSPARENT, WHITE};
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::{AssetStore, RgbaImage};
use crate::document::{Aabb, Notebook, Page, PageBackground, A4_WIDTH_MM, PAGE_WIDTH};
use crate::export::{export_notebook_to_bytes, ExportStrategy};
/// Auflösung des PNG-Exports einer Seite.
pub const PAGE_PNG_DPI: f32 = 200.0;
/// Auflösung einer in die Zwischenablage kopierten Auswahl.
pub const SELECTION_PNG_DPI: f32 = 150.0;
/// Hintergrund beim Bild-Export.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageBackground {
/// Weißes Papier mit Raster bzw. importierter PDF-Seite
Paper,
/// Transparent, nur Bilder und Tinte (ohne Raster und ohne importierte PDF-Seite)
Transparent,
}
/// Erzeugt ein einseitiges PDF aus `page`.
fn single_page_pdf(page: &Page, assets: Option<&AssetStore>) -> Result<Vec<u8>, Box<dyn Error>> {
let notebook = Notebook::from_pages(vec![page.clone()], 0, 1, page.id + 1);
export_notebook_to_bytes(&notebook, ExportStrategy::FilledPolygon, assets)
}
/// Seite ohne Raster und PDF-Hintergrund (für transparenten Export und Auswahlen).
fn content_only(page: &Page) -> Page {
let mut page = page.clone();
page.background = PageBackground::Blank;
page.pdf_background = None;
page
}
/// Rastert die erste Seite eines PDFs mit `dpi`; Farben ohne Vormultiplikation.
fn rasterize(pdf_bytes: Vec<u8>, dpi: f32, transparent: bool) -> Result<RgbaImage, Box<dyn Error>> {
let pdf = Pdf::new(pdf_bytes).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let page = pdf.pages().first().ok_or("PDF ohne Seite")?;
let scale = dpi / 72.0;
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: if transparent { TRANSPARENT } else { WHITE },
},
);
let mut pixels = pixmap.data_as_u8_slice().to_vec();
// hayro liefert vormultiplizierte Farben; PNG erwartet gerade Farben
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels,
})
}
/// Kodiert ein Bild als PNG.
pub fn encode_png(image: &RgbaImage) -> Result<Vec<u8>, Box<dyn Error>> {
let buffer = image::RgbaImage::from_raw(image.width, image.height, image.pixels.clone())
.ok_or("Pixeldaten passen nicht zur Bildgröße")?;
let mut out = Vec::new();
image::DynamicImage::ImageRgba8(buffer)
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)?;
Ok(out)
}
/// Seite als PNG (A4 mit [`PAGE_PNG_DPI`]).
pub fn page_png(
page: &Page,
assets: Option<&AssetStore>,
background: ImageBackground,
) -> Result<Vec<u8>, Box<dyn Error>> {
let image = match background {
ImageBackground::Paper => rasterize(single_page_pdf(page, assets)?, PAGE_PNG_DPI, false)?,
ImageBackground::Transparent => rasterize(
single_page_pdf(&content_only(page), assets)?,
PAGE_PNG_DPI,
true,
)?,
};
encode_png(&image)
}
/// Seite als SVG (Vektor; Schriften importierter PDFs werden zu Pfaden).
pub fn page_svg(page: &Page, assets: Option<&AssetStore>) -> Result<String, Box<dyn Error>> {
let pdf =
Pdf::new(single_page_pdf(page, assets)?).map_err(|e| format!("PDF nicht lesbar: {e:?}"))?;
let pdf_page = pdf.pages().first().ok_or("PDF ohne Seite")?;
Ok(hayro_svg::convert(
pdf_page,
&hayro_svg::RenderCache::new(),
&InterpreterSettings::default(),
&hayro_svg::SvgRenderSettings {
bg_color: [255, 255, 255, 255],
},
))
}
/// Bild der Objekte `ids` auf `page`, auf ihren Umriss zugeschnitten, mit transparentem
/// Hintergrund. `None`, wenn keines der Objekte auf der Seite liegt.
pub fn selection_image(
page: &Page,
ids: &[u64],
assets: Option<&AssetStore>,
) -> Result<Option<RgbaImage>, Box<dyn Error>> {
let mut selected = content_only(page);
selected.strokes.retain(|s| ids.contains(&s.id));
let Some(bounds) = Page::selection_aabb(&selected.strokes) else {
return Ok(None);
};
let max_width = selected.strokes.iter().map(|s| s.width).fold(0.0, f32::max);
let bounds: Aabb = bounds.expanded(max_width / 2.0 + 4.0);
let full = rasterize(single_page_pdf(&selected, assets)?, SELECTION_PNG_DPI, true)?;
// Seiteneinheiten → Pixel
let px_per_unit = SELECTION_PNG_DPI / 25.4 * A4_WIDTH_MM / PAGE_WIDTH;
let x0 = ((bounds.min.x * px_per_unit).floor().max(0.0) as u32).min(full.width);
let y0 = ((bounds.min.y * px_per_unit).floor().max(0.0) as u32).min(full.height);
let x1 = ((bounds.max.x * px_per_unit).ceil().max(0.0) as u32).min(full.width);
let y1 = ((bounds.max.y * px_per_unit).ceil().max(0.0) as u32).min(full.height);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for y in y0..y1 {
let start = ((y * full.width + x0) * 4) as usize;
pixels.extend_from_slice(&full.pixels[start..start + (w * 4) as usize]);
}
Ok(Some(RgbaImage {
width: w,
height: h,
pixels,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Stroke, StylusSample, PAGE_HEIGHT};
fn page_with_line() -> Page {
let mut page = Page::new(1, PageBackground::Grid5mm);
let samples: Vec<StylusSample> = (0..=20)
.map(|i| StylusSample {
position: glam::Vec2::new(500.0 + i as f32 * 20.0, 1000.0),
pressure: 0.6,
timestamp: i as f64 * 0.01,
})
.collect();
let vertices = crate::storage::reconstruct_stroke_vertices(
&samples,
[0.9, 0.1, 0.1, 1.0],
20.0,
Default::default(),
true,
);
let mut stroke = Stroke::new(7, samples, vertices, [0.9, 0.1, 0.1, 1.0]);
stroke.width = 20.0;
page.strokes.push(stroke);
page
}
fn decode(png: &[u8]) -> image::RgbaImage {
image::load_from_memory(png).unwrap().into_rgba8()
}
#[test]
fn test_page_png_paper_and_transparent() {
let page = page_with_line();
let paper = decode(&page_png(&page, None, ImageBackground::Paper).unwrap());
// A4 mit 200 dpi
assert_eq!(paper.width(), (A4_WIDTH_MM / 25.4 * PAGE_PNG_DPI) as u32);
assert_eq!(paper.get_pixel(5, 5).0[3], 255, "Papier ist deckend");
let clear = decode(&page_png(&page, None, ImageBackground::Transparent).unwrap());
assert_eq!(clear.get_pixel(5, 5).0[3], 0, "Hintergrund ist transparent");
// Die Linie ist rot und deckend
let px_per_unit = clear.width() as f32 / PAGE_WIDTH;
let line = clear.get_pixel((700.0 * px_per_unit) as u32, (1000.0 * px_per_unit) as u32);
assert!(
line.0[0] > 180 && line.0[1] < 80 && line.0[3] > 200,
"{line:?}"
);
assert!(clear.height() as f32 / clear.width() as f32 - PAGE_HEIGHT / PAGE_WIDTH < 0.01);
}
#[test]
fn test_page_svg_contains_ink() {
let svg = page_svg(&page_with_line(), None).unwrap();
assert!(svg.starts_with("<svg") || svg.contains("<svg"));
assert!(svg.contains("<path"));
assert!(svg.trim_end().ends_with("</svg>"));
}
#[test]
fn test_selection_image_is_cropped() {
let page = page_with_line();
let image = selection_image(&page, &[7], None).unwrap().unwrap();
// Linie 400 Einheiten (40 mm) lang: Bild etwas breiter als 40 mm bei 150 dpi
let expected = 40.0 / 25.4 * SELECTION_PNG_DPI;
assert!(
(image.width as f32) > expected && (image.width as f32) < expected * 1.25,
"{} px",
image.width
);
assert!(image.height < image.width / 3);
assert!(image.pixels.as_chunks::<4>().0.iter().any(|p| p[3] > 200));
assert!(selection_image(&page, &[99], None).unwrap().is_none());
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
//!
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_syntax::{LoadPdfError, Pdf};
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{PixmapSettings, RenderCache, RenderSettings};
use crate::assets::RgbaImage;
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
/// Größte Seitenzahl, die importiert wird.
pub const MAX_IMPORT_PAGES: usize = 2000;
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
/// berücksichtigt).
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
let pdf = open(bytes)?;
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
if sizes.is_empty() {
return Err("Das PDF enthält keine Seiten".into());
}
if sizes.len() > MAX_IMPORT_PAGES {
return Err(format!(
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
sizes.len()
));
}
Ok(sizes)
}
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
Pdf::new(bytes).map_err(|e| match e {
LoadPdfError::Decryption(_) => {
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
.to_string()
}
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
})
}
/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist,
/// auf weißem Grund.
pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage, String> {
let page = pdf
.pages()
.get(index)
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
let (w, h) = page.render_dimensions();
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
let pixmap = hayro::render(
page,
&RenderCache::new(),
&InterpreterSettings::default(),
&RenderSettings::default(),
&PixmapSettings {
x_scale: scale,
y_scale: scale,
bg_color: WHITE,
},
);
// Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe
Ok(RgbaImage {
width: pixmap.width() as u32,
height: pixmap.height() as u32,
pixels: pixmap.data_as_u8_slice().to_vec(),
})
}
/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild).
pub fn render_file_page(
path: &std::path::Path,
index: usize,
max_pixels: u32,
) -> Result<RgbaImage, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
render_page(&open(bytes)?, index, max_pixels)
}
struct RenderRequest {
key: String,
path: PathBuf,
page: usize,
}
/// Ergebnis eines Render-Auftrags.
pub type RenderResult = Result<RgbaImage, String>;
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
pub struct PdfRenderService {
requests: Sender<RenderRequest>,
results: Receiver<(String, RenderResult)>,
pending: HashSet<String>,
ready: HashMap<String, RenderResult>,
}
impl PdfRenderService {
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
let (res_tx, res_rx) = mpsc::channel();
std::thread::Builder::new()
.name("pdf-render".into())
.spawn(move || {
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
while let Ok(request) = req_rx.recv() {
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
open_pdf = std::fs::read(&request.path)
.map_err(|e| e.to_string())
.and_then(open)
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
.ok()
.map(|pdf| (request.path.clone(), pdf));
}
let result = match &open_pdf {
Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS),
None => Err("PDF kann nicht geöffnet werden".into()),
};
if res_tx.send((request.key, result)).is_err() {
break;
}
on_ready();
}
})
.expect("PDF-Render-Thread konnte nicht gestartet werden");
Self {
requests: req_tx,
results: res_rx,
pending: HashSet::new(),
ready: HashMap::new(),
}
}
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
/// [`Self::take`] liefert es später sofort.
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
self.collect();
if self.ready.contains_key(key) || self.pending.contains(key) {
return;
}
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
if self.ready.len() >= 2 {
self.ready.clear();
}
self.request(key, path, page);
}
fn collect(&mut self) {
while let Ok((done, result)) = self.results.try_recv() {
self.pending.remove(&done);
self.ready.insert(done, result);
}
}
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool {
if !self.pending.insert(key.to_string()) {
return true;
}
let request = RenderRequest {
key: key.to_string(),
path,
page,
};
if self.requests.send(request).is_err() {
self.pending.remove(key);
return false;
}
true
}
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> {
self.collect();
if let Some(result) = self.ready.remove(key) {
return Some(result);
}
if !self.request(key, path, page) {
return Some(Err("PDF-Render-Thread beendet".into()));
}
None
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
let mut objects: Vec<String> = Vec::new();
let n = pages.len();
// 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objects.push(format!(
"<< /Type /Pages /Kids [{}] /Count {n} >>",
kids.join(" ")
));
for (i, (w, h, rotate)) in pages.iter().enumerate() {
let content_id = 4 + 2 * i;
objects.push(format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>"
));
let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0);
objects.push(format!(
"<< /Length {} >>\nstream\n{content}\nendstream",
content.len()
));
}
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, obj) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
}
let xref_pos = out.len();
out.extend_from_slice(
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
);
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
objects.len() + 1
)
.as_bytes(),
);
out
}
#[test]
fn test_page_sizes_and_errors() {
let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]);
let sizes = page_sizes(pdf).unwrap();
assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]);
assert!(page_sizes(b"kein pdf".to_vec()).is_err());
}
#[test]
fn test_render_page_draws_content() {
let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap();
let image = render_page(&pdf, 0, 400).unwrap();
assert_eq!((image.width, image.height), (400, 200));
let px = |x: u32, y: u32| {
let i = ((y * image.width + x) * 4) as usize;
&image.pixels[i..i + 4]
};
// Rotes Quadrat oben links, sonst weiß
assert_eq!(px(20, 20), &[255, 0, 0, 255]);
assert_eq!(px(300, 150), &[255, 255, 255, 255]);
assert!(render_page(&pdf, 5, 400).is_err());
}
#[test]
fn test_render_service_delivers_in_background() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.pdf");
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap();
let (wake_tx, wake_rx) = mpsc::channel();
let mut service = PdfRenderService::new(move || {
let _ = wake_tx.send(());
});
assert!(service.take("a#0", path.clone(), 0).is_none());
// Zweite Anfrage erteilt keinen weiteren Auftrag
assert!(service.take("a#0", path.clone(), 0).is_none());
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
let image = service.take("a#0", path.clone(), 0).unwrap().unwrap();
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
// Vorab rendern: danach sofort verfügbar
service.prefetch("a#0", path.clone(), 0);
wake_rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap();
assert!(service.take("a#0", path, 0).unwrap().is_ok());
}
}
+383
View File
@@ -0,0 +1,383 @@
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
//! Notizen (Text bleibt scharf und auswählbar).
//!
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
use std::collections::{BTreeMap, HashMap};
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
#[derive(Debug, Clone, PartialEq)]
pub struct Underlay {
/// Seitennummer im Ziel-PDF, ab 0
pub target_page: usize,
/// Schlüssel des Quell-PDFs in `sources`
pub source: String,
/// Seitennummer im Quell-PDF, ab 0
pub source_page: usize,
/// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links)
pub rect: [f32; 4],
}
/// Nicht übernommene Unterlagen samt Grund.
pub type FailedUnderlays = Vec<(Underlay, String)>;
/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
pub type Matrix = [f32; 6];
/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden.
pub fn compose(outer: Matrix, inner: Matrix) -> Matrix {
let [a1, b1, c1, d1, e1, f1] = outer;
let [a2, b2, c2, d2, e2, f2] = inner;
[
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
]
}
/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad
/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst
/// in `rect` (links, unten, Breite, Höhe).
pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix {
let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3));
let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]];
let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) {
90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w),
180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h),
270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w),
_ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h),
};
let scale = (rect[2] / shown_w).min(rect[3] / shown_h);
let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]];
compose(place, compose(rotation, to_origin))
}
/// Erbt ein Seitenattribut über die `Parent`-Kette.
fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> {
let mut node = page;
for _ in 0..64 {
if let Ok(value) = node.get(key) {
return Some(value);
}
let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?;
node = doc.get_dictionary(parent).ok()?;
}
None
}
fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object {
doc.dereference(object).map(|(_, o)| o).unwrap_or(object)
}
fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> {
let array = resolve(doc, object?).as_array().ok()?;
let values: Vec<f32> = array
.iter()
.filter_map(|v| resolve(doc, v).as_float().ok())
.collect();
let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?;
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject.
/// Liefert das XObject und die Matrix für die Platzierung in `rect`.
fn page_as_form(
doc: &Document,
page_id: ObjectId,
rect: [f32; 4],
) -> Result<(Stream, Matrix), String> {
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
let media =
read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]);
let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) {
// Sichtbarer Bereich: Schnitt aus Crop- und Media-Box
Some(c) => [
c[0].max(media[0]),
c[1].max(media[1]),
c[2].min(media[2]),
c[3].min(media[3]),
],
None => media,
};
let rotate = inherited(doc, page, b"Rotate")
.and_then(|r| resolve(doc, r).as_i64().ok())
.unwrap_or(0);
let resources = inherited(doc, page, b"Resources")
.cloned()
.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
// Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen,
// mehrere werden entpackt und aneinandergehängt
let streams = doc.get_page_contents(page_id);
let (content, filter) = match streams.as_slice() {
[single] => {
let stream = doc
.get_object(*single)
.and_then(Object::as_stream)
.map_err(|e| e.to_string())?;
let filter = ["Filter", "DecodeParms"]
.into_iter()
.filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone())))
.collect::<Vec<_>>();
(stream.content.clone(), filter)
}
_ => {
let mut content = Vec::new();
for id in streams {
let stream = doc
.get_object(id)
.and_then(Object::as_stream)
.map_err(|e| e.to_string())?;
let data = stream
.decompressed_content()
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
content.extend_from_slice(&data);
content.push(b'\n');
}
(content, Vec::new())
}
};
let mut dict = Dictionary::new();
dict.set("Type", Object::Name(b"XObject".to_vec()));
dict.set("Subtype", Object::Name(b"Form".to_vec()));
dict.set(
"BBox",
Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()),
);
dict.set("Resources", resources);
for (key, value) in filter {
dict.set(key, value);
}
Ok((
Stream::new(dict, content),
placement_matrix(bbox, rotate, rect),
))
}
/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes.
/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten
/// (z. B. verschlüsselte PDFs), samt Grund.
pub fn apply_underlays(
target_bytes: &[u8],
underlays: &[Underlay],
sources: &HashMap<String, Vec<u8>>,
) -> Result<(Vec<u8>, FailedUnderlays), String> {
let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?;
let target_pages: Vec<ObjectId> = target.get_pages().into_values().collect();
let mut failed = Vec::new();
// Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen
let mut loaded: HashMap<&str, Result<BTreeMap<u32, ObjectId>, String>> = HashMap::new();
for underlay in underlays {
if loaded.contains_key(underlay.source.as_str()) {
continue;
}
let result = (|| {
let bytes = sources
.get(&underlay.source)
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
if source.is_encrypted() {
return Err("Quell-PDF ist verschlüsselt".to_string());
}
source.renumber_objects_with(target.max_id + 1);
let pages = source.get_pages();
target.max_id = target.max_id.max(source.max_id);
// Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern
// mit `prune_objects` entfernt
target.objects.extend(source.objects);
Ok(pages)
})();
loaded.insert(&underlay.source, result);
}
for underlay in underlays {
let result = (|| {
let pages = loaded[underlay.source.as_str()]
.as_ref()
.map_err(Clone::clone)?;
let source_page = *pages
.get(&(underlay.source_page as u32 + 1))
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
let target_page = *target_pages
.get(underlay.target_page)
.ok_or_else(|| "Zielseite fehlt".to_string())?;
let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?;
let form_id = target.add_object(Object::Stream(form));
let name = format!("InkPdf{}", form_id.0);
add_xobject(&mut target, target_page, &name, form_id)?;
let ops = format!(
"q {} {} {} {} {} {} cm /{name} Do Q\n",
matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]
);
prepend_content(&mut target, target_page, ops.into_bytes())
})();
if let Err(err) = result {
failed.push((underlay.clone(), err));
}
}
target.prune_objects();
// Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert)
target.compress();
let mut out = Vec::new();
target.save_to(&mut out).map_err(|e| e.to_string())?;
Ok((out, failed))
}
/// Trägt ein XObject in die Ressourcen der Seite ein.
fn add_xobject(
doc: &mut Document,
page_id: ObjectId,
name: &str,
id: ObjectId,
) -> Result<(), String> {
// Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen)
let resources = {
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) {
Some(Object::Dictionary(d)) => d.clone(),
_ => Dictionary::new(),
}
};
let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) {
Ok(Object::Dictionary(d)) => d.clone(),
_ => Dictionary::new(),
};
xobjects.set(name, Object::Reference(id));
let mut resources = resources;
resources.set("XObject", Object::Dictionary(xobjects));
let page = doc
.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| e.to_string())?;
page.set("Resources", Object::Dictionary(resources));
Ok(())
}
/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst).
fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec<u8>) -> Result<(), String> {
let existing: Vec<Object> = doc
.get_page_contents(page_id)
.into_iter()
.map(Object::Reference)
.collect();
let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content)));
let mut contents = vec![Object::Reference(id)];
contents.extend(existing);
let page = doc
.get_object_mut(page_id)
.and_then(Object::as_dict_mut)
.map_err(|e| e.to_string())?;
page.set("Contents", Object::Array(contents));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) {
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
}
#[test]
fn test_placement_matrix() {
// Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20)
let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]);
assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0)));
assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0)));
// Verschobene MediaBox
let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]);
assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0)));
// Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200):
// die linke untere Ecke landet links oben
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]);
assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0)));
assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0)));
assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0)));
// 180° und 270° (= -90°)
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]);
assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0)));
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]);
assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0)));
}
#[test]
fn test_apply_underlays_embeds_source_pages() {
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]);
let sources = HashMap::from([("folien".to_string(), source)]);
let underlays = vec![
Underlay {
target_page: 0,
source: "folien".into(),
source_page: 0,
rect: [0.0, 421.0, 595.0, 421.0],
},
Underlay {
target_page: 1,
source: "folien".into(),
source_page: 5,
rect: [0.0, 0.0, 595.0, 842.0],
},
Underlay {
target_page: 1,
source: "fehlt".into(),
source_page: 0,
rect: [0.0, 0.0, 595.0, 842.0],
},
];
let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap();
// Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt
assert_eq!(failed.len(), 2);
let doc = Document::load_mem(&bytes).unwrap();
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
assert_eq!(
pages.len(),
2,
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
);
let first = doc.get_page_content(pages[0]).unwrap();
let text = String::from_utf8_lossy(&first);
assert!(
text.starts_with("q "),
"Unterlage muss zuerst gezeichnet werden: {text}"
);
assert!(text.contains("Do Q"));
// Ursprünglicher Seiteninhalt bleibt erhalten
assert!(text.contains("re f"));
// Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen
let page = doc.get_dictionary(pages[0]).unwrap();
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap();
let (_, form_ref) = xobjects.iter().next().unwrap();
let form = doc
.get_object(form_ref.as_reference().unwrap())
.unwrap()
.as_stream()
.unwrap();
assert_eq!(
form.dict.get(b"Subtype").unwrap().as_name().unwrap(),
b"Form"
);
assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg"));
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
//!
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
//! übersteuert die Wahl.
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayBackend {
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
Automatic,
X11,
Wayland,
}
/// Wählt das Anzeigesystem.
///
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
/// - `is_wsl`: läuft unter WSL
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
pub fn choose_display_backend(
override_value: Option<&str>,
is_wsl: bool,
has_x11_display: bool,
) -> DisplayBackend {
match override_value
.map(|v| v.trim().to_ascii_lowercase())
.as_deref()
{
Some("x11") => return DisplayBackend::X11,
Some("wayland") => return DisplayBackend::Wayland,
_ => {}
}
if is_wsl && has_x11_display {
DisplayBackend::X11
} else {
DisplayBackend::Automatic
}
}
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
pub fn is_wsl() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
/// Wahl anhand der aktuellen Umgebung.
pub fn display_backend_from_env() -> DisplayBackend {
choose_display_backend(
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
is_wsl(),
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_choose_display_backend() {
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
assert_eq!(
choose_display_backend(None, true, true),
DisplayBackend::X11
);
// WSL ohne X11-Display: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, true, false),
DisplayBackend::Automatic
);
// Natives Linux: winit entscheiden lassen
assert_eq!(
choose_display_backend(None, false, true),
DisplayBackend::Automatic
);
// Übersteuerung
assert_eq!(
choose_display_backend(Some("wayland"), true, true),
DisplayBackend::Wayland
);
assert_eq!(
choose_display_backend(Some(" X11 "), false, true),
DisplayBackend::X11
);
assert_eq!(
choose_display_backend(Some("quatsch"), true, true),
DisplayBackend::X11
);
}
}
+60 -13
View File
@@ -115,12 +115,15 @@ impl SelectionState {
Page::selection_aabb(&strokes) Page::selection_aabb(&strokes)
} }
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch. /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
pub fn hit_test_handles( pub fn hit_test_handles(
&self, &self,
page: &Page, page: &Page,
canvas: &CanvasState, canvas: &CanvasState,
screen_pos: Vec2, screen_pos: Vec2,
pixels_per_point: f32,
) -> SelectionHandleHit { ) -> SelectionHandleHit {
let Some(aabb) = self.get_selection_aabb(page) else { let Some(aabb) = self.get_selection_aabb(page) else {
return SelectionHandleHit::None; return SelectionHandleHit::None;
@@ -134,9 +137,10 @@ impl SelectionState {
let screen_center = (screen_min + screen_max) * 0.5; let screen_center = (screen_min + screen_max) * 0.5;
let page_center = (aabb.min + aabb.max) * 0.5; let page_center = (aabb.min + aabb.max) * 0.5;
// 1. Drehgriff testen (26 px oberhalb des oberen Randes) // 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0); let ppp = pixels_per_point;
if (screen_pos - rot_screen).length() <= 14.0 { let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::RotateHandle { return SelectionHandleHit::RotateHandle {
center_page: page_center, center_page: page_center,
}; };
@@ -164,7 +168,7 @@ impl SelectionState {
]; ];
for i in 0..4 { for i in 0..4 {
if (screen_pos - corners_screen[i]).length() <= 12.0 { if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
return SelectionHandleHit::CornerHandle { return SelectionHandleHit::CornerHandle {
corner_idx: i, corner_idx: i,
fixed_corner_page: fixed_corners_page[i], fixed_corner_page: fixed_corners_page[i],
@@ -239,7 +243,12 @@ impl SelectionState {
if self.selected_ids.is_empty() { if self.selected_ids.is_empty() {
return false; return false;
} }
let mut strokes = page.get_strokes(&self.selected_ids); // Bilder behalten ihre Farben
let mut strokes: Vec<_> = page
.get_strokes(&self.selected_ids)
.into_iter()
.filter(|s| !s.is_image())
.collect();
if strokes.is_empty() { if strokes.is_empty() {
return false; return false;
} }
@@ -273,6 +282,16 @@ impl SelectionState {
} }
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui. /// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
const HANDLE_HIT_RADIUS: f32 = 18.0;
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
}
pub fn render_selection_overlay( pub fn render_selection_overlay(
ctx: &egui::Context, ctx: &egui::Context,
selection: &SelectionState, selection: &SelectionState,
@@ -283,6 +302,8 @@ pub fn render_selection_overlay(
egui::Order::Foreground, egui::Order::Foreground,
egui::Id::new("selection_overlay_layer"), egui::Id::new("selection_overlay_layer"),
)); ));
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
let ppp = ctx.pixels_per_point();
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box) // 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
if let DragMode::BoxSelect { if let DragMode::BoxSelect {
@@ -290,8 +311,8 @@ pub fn render_selection_overlay(
current_page, current_page,
} = selection.drag_mode } = selection.drag_mode
{ {
let p1 = canvas.page_to_screen(start_page); let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
let p2 = canvas.page_to_screen(current_page); let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
let min_x = p1[0].min(p2[0]); let min_x = p1[0].min(p2[0]);
let min_y = p1[1].min(p2[1]); let min_y = p1[1].min(p2[1]);
let max_x = p1[0].max(p2[0]); let max_x = p1[0].max(p2[0]);
@@ -303,14 +324,14 @@ pub fn render_selection_overlay(
painter.rect_filled( painter.rect_filled(
rect, rect,
egui::Rounding::same(4.0_f32), egui::Rounding::same(4.0_f32),
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35), egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
); );
painter.rect_stroke( painter.rect_stroke(
rect, rect,
egui::Rounding::same(4.0_f32), egui::Rounding::same(4.0_f32),
egui::Stroke::new( egui::Stroke::new(
1.5_f32, 1.5_f32,
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220), egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
), ),
); );
return; return;
@@ -321,8 +342,8 @@ pub fn render_selection_overlay(
return; return;
}; };
let p_min = canvas.page_to_screen(aabb.min); let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
let p_max = canvas.page_to_screen(aabb.max); let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1])); let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1])); let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
@@ -338,7 +359,7 @@ pub fn render_selection_overlay(
// Dreh-Stängel (Stalk) und Drehgriff // Dreh-Stängel (Stalk) und Drehgriff
let top_center = egui::pos2(rect.center().x, rect.min.y); let top_center = egui::pos2(rect.center().x, rect.min.y);
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32); let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
painter.line_segment( painter.line_segment(
[top_center, rot_handle], [top_center, rot_handle],
@@ -483,4 +504,30 @@ mod tests {
let restored_pos = page.strokes[0].points[0].position; let restored_pos = page.strokes[0].points[0].position;
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
} }
#[test]
fn test_recolor_ignores_images() {
let mut page = Page::new(1, PageBackground::Blank);
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(100.0, 0.0),
Vec2::new(100.0, 50.0),
Vec2::new(0.0, 50.0),
];
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
let undo_depth = page.history.undo_stack.len();
let mut sel = SelectionState::new();
sel.select_single(1);
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.history.undo_stack.len(), undo_depth);
// Bild und Strich: nur der Strich wird umgefärbt
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
sel.select_multiple(vec![1, 2]);
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
}
} }
+230
View File
@@ -0,0 +1,230 @@
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
//!
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::background::PaperTheme;
use crate::storage::{self, StorageError};
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SidebarSide {
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
#[default]
Left,
/// Rechts (für Linkshänder)
Right,
}
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PressureCurve {
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
pub exponent: f32,
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
/// unterdrückt das Grundrauschen empfindlicher Stifte
pub threshold: f32,
}
impl PressureCurve {
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
pub fn apply(&self, raw: f32) -> f32 {
let curve = self.sanitized();
if !raw.is_finite() {
return 0.0;
}
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
normalized.powf(curve.exponent)
}
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
/// gültigen Bereich.
pub fn sanitized(&self) -> Self {
let default = Self::default();
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
if v.is_finite() {
v.clamp(*range.start(), *range.end())
} else {
fallback
}
};
Self {
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
}
}
}
impl Default for PressureCurve {
fn default() -> Self {
Self {
exponent: 1.0,
threshold: 0.0,
}
}
}
/// Benutzereinstellungen der Oberfläche.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub sidebar_side: SidebarSide,
/// Papierfarbe der Anzeige
pub paper_theme: PaperTheme,
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
pub adapt_ink_colors: bool,
/// Druckkennlinie des Stifts
pub pressure_curve: PressureCurve,
}
impl Default for UiSettings {
fn default() -> Self {
Self {
sidebar_side: SidebarSide::default(),
paper_theme: PaperTheme::default(),
adapt_ink_colors: true,
pressure_curve: PressureCurve::default(),
}
}
}
impl UiSettings {
/// Standardpfad gemäß XDG-Spezifikation.
pub fn default_path() -> Option<PathBuf> {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
};
Some(base.join("ink-notes").join("ui.json"))
}
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
pub fn load_from(path: &Path) -> Self {
let mut settings: Self = match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
path.display()
);
Self::default()
}),
Err(_) => Self::default(),
};
settings.pressure_curve = settings.pressure_curve.sanitized();
settings
}
/// Speichert die Einstellungen atomar.
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
storage::atomic_write_json(path, self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_settings_round_trip_and_fallbacks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ink-notes").join("ui.json");
// Fehlende Datei -> Standard (links)
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
let settings = UiSettings {
sidebar_side: SidebarSide::Right,
paper_theme: PaperTheme::Black,
adapt_ink_colors: false,
pressure_curve: PressureCurve {
exponent: 1.8,
threshold: 0.1,
},
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
// Beschädigte Datei -> Standard statt Absturz
std::fs::write(&path, b"{ kaputt").unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
// Unbekannte / fehlende Felder werden toleriert
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
}
#[test]
fn test_old_settings_file_keeps_new_defaults() {
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ui.json");
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
let settings = UiSettings::load_from(&path);
assert_eq!(settings.sidebar_side, SidebarSide::Right);
assert_eq!(settings.paper_theme, PaperTheme::White);
assert!(settings.adapt_ink_colors);
assert_eq!(settings.pressure_curve, PressureCurve::default());
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
std::fs::write(
&path,
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
)
.unwrap();
let curve = UiSettings::load_from(&path).pressure_curve;
assert_eq!(curve.exponent, 3.0);
assert_eq!(curve.threshold, 0.0);
}
#[test]
fn test_pressure_curve() {
// Standard: unverändert
let linear = PressureCurve::default();
for p in [0.0, 0.25, 0.5, 1.0] {
assert!((linear.apply(p) - p).abs() < 1e-6);
}
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
let soft = PressureCurve {
exponent: 0.5,
threshold: 0.0,
};
let hard = PressureCurve {
exponent: 2.0,
threshold: 0.0,
};
assert!(soft.apply(0.5) > 0.6);
assert!(hard.apply(0.5) < 0.3);
// Endpunkte bleiben fest
for curve in [soft, hard] {
assert_eq!(curve.apply(0.0), 0.0);
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
}
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
let gated = PressureCurve {
exponent: 1.0,
threshold: 0.2,
};
assert_eq!(gated.apply(0.15), 0.0);
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
// Monoton steigend
let mut last = -1.0;
for i in 0..=100 {
let v = hard.apply(i as f32 / 100.0);
assert!(v >= last);
last = v;
}
// Ungültige Eingaben ergeben nie NaN
assert_eq!(linear.apply(f32::NAN), 0.0);
assert_eq!(linear.apply(7.0), 1.0);
}
}
+45
View File
@@ -14,6 +14,8 @@ struct VertexOutput {
struct CameraUniforms { struct CameraUniforms {
transform_scale: vec2<f32>, transform_scale: vec2<f32>,
transform_offset: vec2<f32>, transform_offset: vec2<f32>,
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
ink_params: vec4<f32>,
}; };
@group(0) @binding(0) @group(0) @binding(0)
@@ -32,3 +34,46 @@ fn vs_main(in: VertexInput) -> VertexOutput {
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return in.color; return in.color;
} }
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
@fragment
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
if (camera.ink_params.x < 0.5) {
return in.color;
}
let max_c = max(max(in.color.r, in.color.g), in.color.b);
let min_c = min(min(in.color.r, in.color.g), in.color.b);
let lift = max(1.0 - max_c - min_c, 0.0);
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
}
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
struct ImageVertexInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
};
struct ImageVertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(1) @binding(0)
var image_texture: texture_2d<f32>;
@group(1) @binding(1)
var image_sampler: sampler;
@vertex
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
var out: ImageVertexOutput;
let ndc = in.position * camera.transform_scale + camera.transform_offset;
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
out.uv = in.uv;
return out;
}
@fragment
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
return textureSample(image_texture, image_sampler, in.uv);
}
+16 -1
View File
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
} }
} }
impl SplineConfig {
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
pub fn from_width_units(width_units: f32) -> Self {
let base_radius = (width_units / 2.0).max(0.25);
Self {
min_radius: (base_radius * 0.5).max(0.15),
max_radius: (base_radius * 1.5).max(0.35),
samples_per_segment: 16,
}
}
}
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r). /// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 { pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
let p = pressure.clamp(0.0, 1.0); let p = pressure.clamp(0.0, 1.0);
@@ -653,7 +665,10 @@ pub fn finalize_stroke_with_config(
.map(|v| InkVertex::new(v.position[0], v.position[1], color)) .map(|v| InkVertex::new(v.position[0], v.position[1], color))
.collect(); .collect();
Stroke::new(stroke_id, decimated, ink_vertices, color) let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
stroke.width = config.min_radius + config.max_radius;
stroke.finalized = true;
stroke
} }
#[cfg(test)] #[cfg(test)]
+1841 -99
View File
File diff suppressed because it is too large. Load diff
+2730 -1026
View File
File diff suppressed because it is too large. Load diff
+195 -59
View File
@@ -7,7 +7,7 @@
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz) //! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten) //! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien //! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
//! - Erweiterbare Schnittstelle für optionale Signaturprüfung (.sig / .minisig) //! - Pflicht-Signaturprüfung der `SHA256SUMS.txt` via Minisign (eingebauter öffentlicher Schlüssel)
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename` //! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
//! - Echtes Fortschritts-Streaming (~10 Updates/s) //! - Echtes Fortschritts-Streaming (~10 Updates/s)
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv) //! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
@@ -313,22 +313,78 @@ pub fn verify_checksum(
Ok(()) Ok(())
} }
/// Status einer Signaturverifikation (.sig / .minisig). /// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
#[derive(Debug, Clone, PartialEq, Eq)] /// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
pub enum SignatureVerificationStatus { /// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
NotPresent, /// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
Verified { signer: String }, pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
Failed(String), "RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
/// Name des Prüfsummen-Assets eines Releases.
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
/// Name der Minisign-Signatur zu [`CHECKSUMS_ASSET_NAME`].
pub const CHECKSUMS_SIGNATURE_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
/// Prüft die Minisign-Signatur der Prüfsummendatei. Nur aktuelle (prehashed) Signaturen
/// werden akzeptiert; ohne hinterlegten Schlüssel schlägt die Prüfung immer fehl.
pub fn verify_checksums_signature(
checksums: &[u8],
signature_text: &str,
public_key_b64: &str,
) -> Result<(), String> {
let key = public_key_b64.trim();
if key.is_empty() {
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
}
let public_key = minisign_verify::PublicKey::from_base64(key)
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
let signature = minisign_verify::Signature::decode(signature_text)
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
public_key
.verify(checksums, &signature, false)
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}."))
} }
/// Erweiterbare Schnittstelle für kryptografische Signaturprüfung. /// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
pub fn verify_signature_if_present( fn fetch_release_text(
_asset_file: &Path, agent: &ureq::Agent,
_sig_bytes: Option<&[u8]>, release: &GiteaRelease,
) -> Result<SignatureVerificationStatus, String> { name: &str,
// Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key config: &UpdaterConfig,
// im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen. ) -> Result<String, String> {
Ok(SignatureVerificationStatus::NotPresent) let asset = release
.assets
.iter()
.find(|a| a.name == name)
.ok_or_else(|| format!("Release enthält keine {name}."))?;
http_get_with_auth_guard(
agent,
&asset.browser_download_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
)
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))?
.into_string()
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}."))
}
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
/// Signatur zurück.
fn fetch_verified_checksums(
agent: &ureq::Agent,
release: &GiteaRelease,
config: &UpdaterConfig,
public_key_b64: &str,
) -> Result<String, String> {
// Ohne Schlüssel gar nicht erst Netzwerkanfragen stellen
if public_key_b64.trim().is_empty() {
return verify_checksums_signature(b"", "", public_key_b64).map(|()| String::new());
}
let checksums = fetch_release_text(agent, release, CHECKSUMS_ASSET_NAME, config)?;
let signature = fetch_release_text(agent, release, CHECKSUMS_SIGNATURE_ASSET_NAME, config)?;
verify_checksums_signature(checksums.as_bytes(), &signature, public_key_b64)?;
Ok(checksums)
} }
/// Event-Typen für den asynchronen Worker. /// Event-Typen für den asynchronen Worker.
@@ -368,6 +424,17 @@ pub enum UpdaterStatus {
Error(String), Error(String),
} }
/// Lehnt jede URL ohne HTTPS ab, sofern unsicheres HTTP nicht ausdrücklich erlaubt ist.
fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), String> {
match url.scheme() {
"https" => Ok(()),
"http" if allow_insecure_http => Ok(()),
scheme => Err(format!(
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
)),
}
}
/// Erstellt einen ureq Agent mit sauberen Timeouts. /// Erstellt einen ureq Agent mit sauberen Timeouts.
fn create_http_agent() -> ureq::Agent { fn create_http_agent() -> ureq::Agent {
ureq::AgentBuilder::new() ureq::AgentBuilder::new()
@@ -384,12 +451,15 @@ fn http_get_with_auth_guard(
initial_url_str: &str, initial_url_str: &str,
token: Option<&str>, token: Option<&str>,
max_redirects: usize, max_redirects: usize,
allow_insecure_http: bool,
) -> Result<ureq::Response, String> { ) -> Result<ureq::Response, String> {
let mut current_url = url::Url::parse(initial_url_str) let mut current_url = url::Url::parse(initial_url_str)
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?; .map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
let initial_origin = current_url.origin().ascii_serialization(); let initial_origin = current_url.origin().ascii_serialization();
for _ in 0..=max_redirects { for _ in 0..=max_redirects {
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
ensure_secure_scheme(&current_url, allow_insecure_http)?;
let mut req = agent.get(current_url.as_str()); let mut req = agent.get(current_url.as_str());
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist // Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
@@ -500,7 +570,13 @@ impl UpdaterController {
); );
let agent = create_http_agent(); let agent = create_http_agent();
match http_get_with_auth_guard(&agent, &api_url, config.auth_token.as_deref(), 5) { match http_get_with_auth_guard(
&agent,
&api_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
) {
Ok(resp) => match resp.into_json::<GiteaRelease>() { Ok(resp) => match resp.into_json::<GiteaRelease>() {
Ok(release) => { Ok(release) => {
if release.prerelease && !config.include_prereleases { if release.prerelease && !config.include_prereleases {
@@ -563,12 +639,37 @@ impl UpdaterController {
let target_file = cache_dir.join(&safe_asset_name); let target_file = cache_dir.join(&safe_asset_name);
let agent = create_http_agent(); let agent = create_http_agent();
// 1. Download des Release-Assets mit Fortschritts-Streaming // 1. Signierte Prüfsummen laden und verifizieren, BEVOR das Paket geladen wird
let checksums_content = match active_release.as_ref() {
Some(release) => match fetch_verified_checksums(
&agent,
release,
&config,
UPDATE_SIGNING_PUBLIC_KEY,
) {
Ok(text) => text,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"{e} Download abgelehnt."
)));
return;
}
},
None => {
let _ = tx.send(UpdaterEvent::DownloadError(
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
));
return;
}
};
// 2. Download des Release-Assets mit Fortschritts-Streaming
let resp = match http_get_with_auth_guard( let resp = match http_get_with_auth_guard(
&agent, &agent,
&asset.browser_download_url, &asset.browser_download_url,
config.auth_token.as_deref(), config.auth_token.as_deref(),
5, 5,
config.allow_insecure_http,
) { ) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
@@ -646,53 +747,13 @@ impl UpdaterController {
drop(file); drop(file);
// 2. Integritätsprüfung gegen SHA256SUMS.txt // 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
let checksum_asset = active_release
.as_ref()
.and_then(|r| r.assets.iter().find(|a| a.name == "SHA256SUMS.txt"));
let checksums_content = match checksum_asset {
Some(ca) => {
match http_get_with_auth_guard(
&agent,
&ca.browser_download_url,
config.auth_token.as_deref(),
5,
) {
Ok(c_resp) => match c_resp.into_string() {
Ok(text) => text,
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte SHA256SUMS.txt nicht dekodieren: {e}. Datei gelöscht."
)));
return;
}
},
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte SHA256SUMS.txt nicht laden: {e}. Datei aus Sicherheitsgründen gelöscht."
)));
return;
}
}
}
None => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(
"Release enthält keine SHA256SUMS.txt-Prüfsummen. Download abgelehnt und gelöscht.".to_string(),
));
return;
}
};
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) { if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
let _ = tx.send(UpdaterEvent::DownloadError(err)); let _ = tx.send(UpdaterEvent::DownloadError(err));
return; return;
} }
// 3. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen // 4. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
if safe_asset_name.ends_with(".AppImage") { if safe_asset_name.ends_with(".AppImage") {
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -996,6 +1057,81 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
assert!(allowed_insecure.validate_url().is_ok()); assert!(allowed_insecure.validate_url().is_ok());
} }
/// Nur für Tests erzeugtes Minisign-Schlüsselpaar (der geheime Schlüssel wurde verworfen).
const TEST_PUBLIC_KEY: &str = "RWQr5S8CVgsjS4UA3uY0xqTDua4uRSSzKWpFfJKLCypjgP3NGt9Tmdbh";
const TEST_CHECKSUMS: &str = "abc123 ink-notes_9.9.9_amd64.deb\n";
const TEST_SIGNATURE: &str = "untrusted comment: signature from rsign secret key
RUQr5S8CVgsjS5BYMUx1ICe1E356aP7wesQXw+oKF6Rd8WsvdF1dmCb1ffonXgJ2GhWE5iAWPXQaT8Y/Y4zLIpyS1PA8JCe8eAM=
trusted comment: ink-notes test
iw0NOedyAQg3emO36fHjyfn+AbnI7Idyl+NwFTDJX1YceLM1jSfjIfeIc0D49IQXyGTpM/QnkZJkRO/eI8ZKBQ==
";
#[test]
fn test_checksums_signature_verification() {
// Gültige Signatur
assert!(verify_checksums_signature(
TEST_CHECKSUMS.as_bytes(),
TEST_SIGNATURE,
TEST_PUBLIC_KEY
)
.is_ok());
// Manipulierte Prüfsummen (z. B. ausgetauschter Hash)
let tampered = TEST_CHECKSUMS.replace("abc123", "abc124");
assert!(
verify_checksums_signature(tampered.as_bytes(), TEST_SIGNATURE, TEST_PUBLIC_KEY)
.is_err()
);
// Fremder Schlüssel
let other_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, other_key)
.is_err()
);
// Kein Schlüssel hinterlegt -> strikt ablehnen
let err =
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, "").unwrap_err();
assert!(err.contains("kein Signaturschlüssel"), "{err}");
// Fehlende / kaputte Signatur
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), "", TEST_PUBLIC_KEY).is_err()
);
// Altes (nicht prehashed) Signaturformat wird nicht akzeptiert
let legacy_sig = "untrusted comment: signature from minisign secret key
RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=
trusted comment: timestamp:1555779966\tfile:test
QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA==";
assert!(verify_checksums_signature(b"test", legacy_sig, other_key).is_err());
}
#[test]
fn test_https_enforced_for_every_request() {
let https = url::Url::parse("https://gitea.pansi.eu/a.deb").unwrap();
let http = url::Url::parse("http://cdn.example.com/a.deb").unwrap();
let ftp = url::Url::parse("ftp://example.com/a.deb").unwrap();
assert!(ensure_secure_scheme(&https, false).is_ok());
assert!(ensure_secure_scheme(&http, false).is_err());
assert!(ensure_secure_scheme(&http, true).is_ok());
assert!(ensure_secure_scheme(&ftp, true).is_err());
// Ein http-Download-Link aus der Release-API wird abgewiesen, bevor eine
// Verbindung aufgebaut wird
let agent = create_http_agent();
let err = http_get_with_auth_guard(
&agent,
"http://127.0.0.1:9/ink-notes.AppImage",
Some("geheim"),
5,
false,
)
.unwrap_err();
assert!(err.contains("HTTPS"), "{err}");
}
#[test] #[test]
fn test_appimage_atomic_self_update() { fn test_appimage_atomic_self_update() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
+14
View File
@@ -0,0 +1,14 @@
{
"format_version": 99,
"id": "future_notebook",
"title": "Zukunfts-Notizbuch",
"created_at": 1800000000,
"modified_at": 1800001000,
"current_page_index": 0,
"pages": [
{ "id": 1, "background": "Grid5mm" }
],
"page_ids": [1],
"next_stroke_id": 10,
"next_page_id": 2
}
+6
View File
@@ -0,0 +1,6 @@
{
"format_version": 99,
"id": 1,
"background": "Blank",
"strokes": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"id": "legacy_notebook",
"title": "Legacy Notizbuch v0",
"created_at": 1700000000,
"modified_at": 1700001000,
"current_page_index": 0,
"page_ids": [1],
"next_stroke_id": 5,
"next_page_id": 2
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": 1,
"background": "DotGrid",
"strokes": [
{
"id": 1,
"color": [0.1, 0.2, 0.3, 1.0],
"samples": [
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
]
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"format_version": 1,
"id": 1,
"background": "Lined",
"strokes": [],
"unknown_experimental_feature": {
"nested_data": [1, 2, 3],
"author": "Antigravity"
},
"random_future_flag": true
}