Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf699b680 | ||
|
|
ade20ab3dc | ||
|
|
f24e7750e6 | ||
|
|
097933882c | ||
|
|
b3578175c2 | ||
|
|
1cb02bf8e9 | ||
|
|
26c4191e0f | ||
|
|
7d683d6e67 | ||
|
|
e399c357d8 | ||
|
|
dd702524ca |
Generated
+1
-1
@@ -1393,7 +1393,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.25.0"
|
||||
version = "0.26.2"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.25.0"
|
||||
version = "0.26.2"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -74,6 +74,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
|
||||
### Updates
|
||||
- Die App prüft auf neue Versionen auf Gitea und installiert sie auf Wunsch. Installiert wird nur, wenn die Minisign-Signatur und die SHA-256-Prüfsumme stimmen (Details unten).
|
||||
- Als Flatpak sucht ink-notes nicht selbst nach Updates; die kommen dann über die Software-Verwaltung (`flatpak update`).
|
||||
|
||||
---
|
||||
|
||||
@@ -190,6 +191,17 @@ Mehr dazu in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md), das Speicherformat
|
||||
|
||||
Veröffentlicht wird über Gitea Actions ([`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml)), sobald ein Tag `vX.Y.Z` gepusht wird: Tests, Pakete bauen, signieren, Release anlegen. Manuell geht es mit `GITEA_TOKEN=… ./scripts/publish-gitea-release.sh`.
|
||||
|
||||
### Flatpak
|
||||
|
||||
Das Manifest liegt in [`packaging/flatpak/`](packaging/flatpak/) (Runtime `org.freedesktop.Platform` 26.08, Rust aus der SDK-Erweiterung `rust-stable`). Lokal bauen und installieren:
|
||||
|
||||
```bash
|
||||
flatpak-builder --user --install --force-clean target/flatpak-build packaging/flatpak/eu.pansi.ink-notes.yml
|
||||
flatpak run eu.pansi.ink-notes
|
||||
```
|
||||
|
||||
Nach jeder Änderung an `Cargo.lock` muss `packaging/flatpak/cargo-sources.json` mit dem [flatpak-cargo-generator](https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo) neu erzeugt werden (`python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json`). In der Sandbox darf ink-notes Bilder, Downloads und Schreibtisch lesen und in den Dokumente-Ordner exportieren; die Notizbücher liegen unter `~/.var/app/eu.pansi.ink-notes/`.
|
||||
|
||||
### Release-Signatur einrichten (einmalig)
|
||||
|
||||
Updates werden in der App nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2) (`cargo install rsign2 --locked`). Schlüsselpaar ohne Passwort erzeugen (es wird nur als CI-Secret verwendet):
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=ink-notes
|
||||
GenericName=Notizbuch- & Inking-App
|
||||
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||
GenericName=Note-Taking & Inking App
|
||||
GenericName[de]=Notizbuch- & Inking-App
|
||||
Comment=Handwritten notes with pen, touch and mouse; annotate PDFs
|
||||
Comment[de]=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||
Exec=ink-notes %F
|
||||
Icon=eu.pansi.ink-notes
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Graphics;Utility;
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
|
||||
Categories=Office;Graphics;
|
||||
Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
|
||||
Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
|
||||
StartupWMClass=eu.pansi.ink-notes
|
||||
MimeType=application/x-inknotes;
|
||||
Actions=NewWindow;
|
||||
|
||||
[Desktop Action NewWindow]
|
||||
Name=Neues Fenster
|
||||
Name=New Window
|
||||
Name[de]=Neues Fenster
|
||||
Exec=ink-notes
|
||||
|
||||
@@ -4,22 +4,34 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>ink-notes</name>
|
||||
<summary>Notizbuch-App für Stift, Touch und Maus</summary>
|
||||
<summary>Handwritten notes with pen, touch and mouse</summary>
|
||||
<summary xml:lang="de">Notizbuch-App für Stift, Touch und Maus</summary>
|
||||
<description>
|
||||
<p>
|
||||
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
|
||||
ink-notes is a notebook app for Linux tablets and convertibles. Handwriting is stored as
|
||||
vectors; write with a pen, your finger, a touchpad or a mouse.
|
||||
</p>
|
||||
<p xml:lang="de">
|
||||
ink-notes ist eine Notizbuch-App für Linux-Tablets und Convertibles. Handschrift wird als
|
||||
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
|
||||
</p>
|
||||
<p>Funktionen:</p>
|
||||
<p>Features:</p>
|
||||
<p xml:lang="de">Funktionen:</p>
|
||||
<ul>
|
||||
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
|
||||
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
|
||||
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
|
||||
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
|
||||
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||
<li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
|
||||
<li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||
<li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
|
||||
<li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
|
||||
<li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</li>
|
||||
<li xml:lang="de">Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||
<li>Select, move, rotate, scale and recolor; undo for every change</li>
|
||||
<li xml:lang="de">Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||
<li>Text boxes, images and PDF import to annotate lecture slides and worksheets</li>
|
||||
<li xml:lang="de">Textfelder, Bilder und PDF-Import zum Beschriften von Vorlesungsfolien und Übungsblättern</li>
|
||||
<li>Notebooks in folders, continuous scrolling, automatic saving in the background</li>
|
||||
<li xml:lang="de">Notizbücher in Ordnern, durchgehendes Scrollen, automatisches Speichern im Hintergrund</li>
|
||||
<li>Export as PDF (with searchable text), PNG or SVG</li>
|
||||
<li xml:lang="de">Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||
@@ -29,12 +41,78 @@
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Graphics</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
<keywords>
|
||||
<keyword>notes</keyword>
|
||||
<keyword>handwriting</keyword>
|
||||
<keyword>stylus</keyword>
|
||||
<keyword>pen</keyword>
|
||||
<keyword>PDF</keyword>
|
||||
<keyword>annotate</keyword>
|
||||
</keywords>
|
||||
<supports>
|
||||
<control>pointing</control>
|
||||
<control>touch</control>
|
||||
<control>tablet</control>
|
||||
</supports>
|
||||
<developer id="eu.pansi">
|
||||
<name>Harald</name>
|
||||
<name>Harald Pansi</name>
|
||||
</developer>
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Annotating imported PDF slides</caption>
|
||||
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.26.2" date="2026-10-13">
|
||||
<description>
|
||||
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.1" date="2026-10-10">
|
||||
<description>
|
||||
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.0" date="2026-10-10">
|
||||
<description>
|
||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.25.0" date="2026-10-09">
|
||||
<description>
|
||||
<p>English user interface; the language follows the system and can be changed in the menu. Holding to snap a shape tolerates a slightly trembling pen.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.24.0" date="2026-10-09">
|
||||
<description>
|
||||
<p>Axis cross from two crossing strokes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.23.0" date="2026-10-09">
|
||||
<description>
|
||||
<p>The on-screen keyboard of GNOME appears when tapping a text field.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# Flatpak-Manifest für ink-notes (Flathub).
|
||||
#
|
||||
# Lokal bauen und installieren (aus dem Repository-Wurzelverzeichnis):
|
||||
# flatpak-builder --user --install --force-clean target/flatpak-build \
|
||||
# packaging/flatpak/eu.pansi.ink-notes.yml
|
||||
#
|
||||
# cargo-sources.json nach jeder Änderung an Cargo.lock neu erzeugen:
|
||||
# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json
|
||||
# (flatpak-builder-tools, https://github.com/flatpak/flatpak-builder-tools/tree/master/cargo)
|
||||
#
|
||||
# Für die Einreichung bei Flathub die Quelle `type: dir` durch das Release-Tag ersetzen
|
||||
# (type: git, url, tag, commit).
|
||||
id: eu.pansi.ink-notes
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: '26.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: ink-notes
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
# Die eingebaute Dateiauswahl (Bilder und PDFs einfügen) arbeitet ohne Desktop-Portal und
|
||||
# bietet Bilder, Downloads und Schreibtisch an; Export (PDF, PNG, SVG) in den Dokumente-Ordner.
|
||||
# Ziehen aus dem Dateimanager geht über das Dokumenten-Portal auch aus anderen Ordnern.
|
||||
- --filesystem=xdg-pictures:ro
|
||||
- --filesystem=xdg-download:ro
|
||||
- --filesystem=xdg-desktop:ro
|
||||
- --filesystem=xdg-documents
|
||||
build-options:
|
||||
append-path: /usr/lib/sdk/rust-stable/bin
|
||||
env:
|
||||
CARGO_HOME: /run/build/ink-notes/cargo
|
||||
modules:
|
||||
- name: ink-notes
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- cargo --offline build --release --locked
|
||||
- install -Dm755 target/release/ink-notes /app/bin/ink-notes
|
||||
- install -Dm644 packaging/eu.pansi.ink-notes.desktop /app/share/applications/eu.pansi.ink-notes.desktop
|
||||
- install -Dm644 packaging/eu.pansi.ink-notes.metainfo.xml /app/share/metainfo/eu.pansi.ink-notes.metainfo.xml
|
||||
- install -Dm644 assets/ink-notes.svg /app/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg
|
||||
- install -Dm644 assets/ink-notes.png /app/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../..
|
||||
skip:
|
||||
- target
|
||||
- .git
|
||||
- .flatpak-builder
|
||||
- cargo-sources.json
|
||||
@@ -1,18 +1,22 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=ink-notes
|
||||
GenericName=Notizbuch- & Inking-App
|
||||
Comment=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||
GenericName=Note-Taking & Inking App
|
||||
GenericName[de]=Notizbuch- & Inking-App
|
||||
Comment=Handwritten notes with pen, touch and mouse; annotate PDFs
|
||||
Comment[de]=Handschriftliche Notizen mit Stift, Touch und Maus; PDFs beschriften
|
||||
Exec=ink-notes %F
|
||||
Icon=eu.pansi.ink-notes
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Graphics;Utility;
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;Text;Annotation;
|
||||
Categories=Office;Graphics;
|
||||
Keywords=Note;Pen;Ink;Inking;Stylus;Drawing;PDF;Vector;Text;Annotation;
|
||||
Keywords[de]=Notiz;Stift;Ink;Inking;Stylus;Zeichnen;PDF;Vektor;Text;Annotation;
|
||||
StartupWMClass=eu.pansi.ink-notes
|
||||
MimeType=application/x-inknotes;
|
||||
Actions=NewWindow;
|
||||
|
||||
[Desktop Action NewWindow]
|
||||
Name=Neues Fenster
|
||||
Name=New Window
|
||||
Name[de]=Neues Fenster
|
||||
Exec=ink-notes
|
||||
|
||||
@@ -4,22 +4,34 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>ink-notes</name>
|
||||
<summary>Notizbuch-App für Stift, Touch und Maus</summary>
|
||||
<summary>Handwritten notes with pen, touch and mouse</summary>
|
||||
<summary xml:lang="de">Notizbuch-App für Stift, Touch und Maus</summary>
|
||||
<description>
|
||||
<p>
|
||||
ink-notes ist eine Notizbuch-App für Linux, geschrieben in Rust mit wgpu. Handschrift wird als
|
||||
ink-notes is a notebook app for Linux tablets and convertibles. Handwriting is stored as
|
||||
vectors; write with a pen, your finger, a touchpad or a mouse.
|
||||
</p>
|
||||
<p xml:lang="de">
|
||||
ink-notes ist eine Notizbuch-App für Linux-Tablets und Convertibles. Handschrift wird als
|
||||
Vektoren gespeichert; geschrieben wird mit Stift, Finger, Touchpad oder Maus.
|
||||
</p>
|
||||
<p>Funktionen:</p>
|
||||
<p>Features:</p>
|
||||
<p xml:lang="de">Funktionen:</p>
|
||||
<ul>
|
||||
<li>Stift und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||
<li>Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||
<li>Formerkennung (Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Koordinatensysteme)</li>
|
||||
<li>Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||
<li>Textfelder, Bilder und PDF-Import zum Beschriften (Querformat-Folien bekommen Querformat-Seiten)</li>
|
||||
<li>Durchgehendes Scrollen, Zoom von 25 bis 1000 Prozent, Papier- und Musterfarben</li>
|
||||
<li>Mehrere Notizbücher, automatisches Speichern im Hintergrund</li>
|
||||
<li>Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||
<li>Pens and highlighter with pressure, smoothing while writing and sharp corners</li>
|
||||
<li xml:lang="de">Stifte und Textmarker mit Druckstärke, Glättung beim Schreiben und Ecken-Erhaltung</li>
|
||||
<li>Shape recognition: hold at the end of a stroke to get clean lines, arrows, circles, rectangles, triangles and coordinate systems</li>
|
||||
<li xml:lang="de">Formerkennung: am Strichende halten für saubere Linien, Pfeile, Kreise, Rechtecke, Dreiecke und Koordinatensysteme</li>
|
||||
<li>Eraser for whole strokes, partial strokes or highlighter only; virtual ruler</li>
|
||||
<li xml:lang="de">Radierer für ganze Striche, Teilstriche oder nur Textmarker; virtuelles Lineal</li>
|
||||
<li>Select, move, rotate, scale and recolor; undo for every change</li>
|
||||
<li xml:lang="de">Auswählen, Verschieben, Drehen, Skalieren und Umfärben; Rückgängig für alle Änderungen</li>
|
||||
<li>Text boxes, images and PDF import to annotate lecture slides and worksheets</li>
|
||||
<li xml:lang="de">Textfelder, Bilder und PDF-Import zum Beschriften von Vorlesungsfolien und Übungsblättern</li>
|
||||
<li>Notebooks in folders, continuous scrolling, automatic saving in the background</li>
|
||||
<li xml:lang="de">Notizbücher in Ordnern, durchgehendes Scrollen, automatisches Speichern im Hintergrund</li>
|
||||
<li>Export as PDF (with searchable text), PNG or SVG</li>
|
||||
<li xml:lang="de">Export als PDF (mit durchsuchbarem Text), PNG oder SVG</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||
@@ -29,12 +41,78 @@
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Graphics</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
<keywords>
|
||||
<keyword>notes</keyword>
|
||||
<keyword>handwriting</keyword>
|
||||
<keyword>stylus</keyword>
|
||||
<keyword>pen</keyword>
|
||||
<keyword>PDF</keyword>
|
||||
<keyword>annotate</keyword>
|
||||
</keywords>
|
||||
<supports>
|
||||
<control>pointing</control>
|
||||
<control>touch</control>
|
||||
<control>tablet</control>
|
||||
</supports>
|
||||
<developer id="eu.pansi">
|
||||
<name>Harald</name>
|
||||
<name>Harald Pansi</name>
|
||||
</developer>
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="vcs-browser">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Notes with highlighter, handwriting and text boxes</caption>
|
||||
<caption xml:lang="de">Notizen mit Textmarker, Handschrift und Textfeldern</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/notes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Recognized shapes: coordinate system, triangle, circle, octahedron</caption>
|
||||
<caption xml:lang="de">Erkannte Formen: Koordinatensystem, Dreieck, Kreis, Oktaeder</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/shapes.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Annotating imported PDF slides</caption>
|
||||
<caption xml:lang="de">Importierte PDF-Folien beschriften</caption>
|
||||
<image>https://gitea.pansi.eu/harald/ink-notes/raw/tag/v0.26.1/packaging/screenshots/pdf.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#a8c8f0</color>
|
||||
<color type="primary" scheme_preference="dark">#1f3a5f</color>
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.26.2" date="2026-10-13">
|
||||
<description>
|
||||
<p>Smoother scrolling through large imported PDFs: pages no longer stall the display while they are prepared, and pages scrolled past quickly are skipped.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.1" date="2026-10-10">
|
||||
<description>
|
||||
<p>Screenshots in the app description for software centers. The app itself is unchanged.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.26.0" date="2026-10-10">
|
||||
<description>
|
||||
<p>Select and move objects with a finger; text and make-space tools also work with a finger. Exports go to the documents folder of the user directories.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.25.0" date="2026-10-09">
|
||||
<description>
|
||||
<p>English user interface; the language follows the system and can be changed in the menu. Holding to snap a shape tolerates a slightly trembling pen.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.24.0" date="2026-10-09">
|
||||
<description>
|
||||
<p>Axis cross from two crossing strokes.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.23.0" date="2026-10-09">
|
||||
<description>
|
||||
<p>The on-screen keyboard of GNOME appears when tapping a text field.</p>
|
||||
</description>
|
||||
</release>
|
||||
</releases>
|
||||
</component>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Notizbuch-App für Stift, Touch und Maus unter Linux (getestet mit Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Flüssigeres Scrollen in großen PDFs**: Neu gerenderte PDF-Seiten halten die Anzeige nicht mehr an, und Seiten, über die man schnell hinwegscrollt, werden übersprungen statt der Reihe nach gerendert
|
||||
- **Screenshots für Flathub**: Die App-Beschreibung (Metainfo) zeigt jetzt drei Screenshots; an der App selbst ändert sich nichts
|
||||
- **Auswählen mit dem Finger**: Im Auswahlwerkzeug wählt und verschiebt ein Finger Objekte (auf leerer Fläche scrollt er weiter); Text und Platz schaffen gehen ebenfalls mit dem Finger
|
||||
- **Flatpak**: Manifest unter packaging/flatpak (Vorbereitung für Flathub); Exporte landen im Dokumente-Ordner aus den Benutzerordnern (z. B. ~/Dokumente)
|
||||
- **Englische Oberfläche / English interface**: ink-notes folgt der Systemsprache (Deutsch oder Englisch); umstellen im ☰-Menü unter „Sprache / Language“
|
||||
- **Halten zum Einrasten**: verträgt jetzt das leichte Zittern eines ruhenden Stifts, damit Formen auch im normalen Stift-Modus einrasten
|
||||
- **Achsenkreuz aus zwei Strichen**: Eine waagerechte und eine senkrechte Gerade über Kreuz zeichnen – die zweite mit Halten oder dem Formen-Werkzeug – und beide werden zum Achsenkreuz mit Pfeilspitzen an allen vier Enden; Rückgängig stellt die erste Gerade wieder her
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+13
-2
@@ -46,7 +46,9 @@ pub fn sanitize_filename_component(title: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
|
||||
/// Ermittelt das Standard-Dokumentenverzeichnis: `$XDG_DOCUMENTS_DIR`, sonst der Eintrag in
|
||||
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
|
||||
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
|
||||
pub fn default_documents_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||
if !dir.trim().is_empty() {
|
||||
@@ -56,7 +58,16 @@ pub fn default_documents_dir() -> PathBuf {
|
||||
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.trim().is_empty() {
|
||||
return PathBuf::from(home).join("Documents");
|
||||
let home = PathBuf::from(home);
|
||||
let user_dirs =
|
||||
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||
if let Some(dir) =
|
||||
crate::file_picker::parse_user_dir(&user_dirs, "XDG_DOCUMENTS_DIR", &home)
|
||||
.filter(|dir| *dir != home)
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
return home.join("Documents");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,11 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("Die beschädigte Originaldatei wurde in Quarantäne gesichert:", "The damaged original file was saved to quarantine:"),
|
||||
("Die Datei ist kein lesbares PDF", "The file is not a readable PDF"),
|
||||
("Die einzige Seite eines Notizbuchs kann nicht gelöscht werden", "The only page of a notebook cannot be deleted"),
|
||||
("Die Flatpak-Version wird über die Software-Verwaltung aktualisiert.", "The Flatpak version is updated through the software manager."),
|
||||
("Die heruntergeladene Datei existiert nicht mehr.", "The downloaded file no longer exists."),
|
||||
("die oberste Ebene", "the top level"),
|
||||
("Die Zwischenablage ist leer", "The clipboard is empty"),
|
||||
("Diese Version wird über die Software-Verwaltung aktualisiert (z. B. GNOME Software oder „flatpak update“).", "This version is updated through the software manager (e.g. GNOME Software or “flatpak update”)."),
|
||||
("Dieser Ordner ist leer.", "This folder is empty."),
|
||||
("Dokument '{title}' erstellt", "Document '{title}' created"),
|
||||
("Dokument '{title}' geöffnet", "Document '{title}' opened"),
|
||||
|
||||
+94
-27
@@ -45,26 +45,57 @@ pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
||||
[tl, tr, bl, bl, tr, br]
|
||||
}
|
||||
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base.clone()];
|
||||
let Some(mut current) =
|
||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
||||
else {
|
||||
return levels;
|
||||
};
|
||||
while current.width() > 1 || current.height() > 1 {
|
||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
||||
levels.push(RgbaImage {
|
||||
width: w,
|
||||
height: h,
|
||||
pixels: current.as_raw().clone(),
|
||||
});
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1. Für eine gerenderte
|
||||
/// PDF-Seite dauert das spürbar (2560 px: einige 10 ms); deshalb erzeugt sie der
|
||||
/// PDF-Render-Thread und nicht der Haupt-Thread.
|
||||
pub fn mip_chain(base: RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base];
|
||||
loop {
|
||||
let current = &levels[levels.len() - 1];
|
||||
if current.width <= 1 && current.height <= 1 {
|
||||
break;
|
||||
}
|
||||
let next = half_size(current);
|
||||
levels.push(next);
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Halbiert ein Bild: Mittelwert aus je 2 × 2 Pixeln (bei ungerader Kante entfällt die
|
||||
/// letzte Spalte bzw. Zeile, bei Kantenlänge 1 wird nur in der anderen Richtung gemittelt).
|
||||
fn half_size(image: &RgbaImage) -> RgbaImage {
|
||||
let (w, h) = (image.width as usize, image.height as usize);
|
||||
let (nw, nh) = ((w / 2).max(1), (h / 2).max(1));
|
||||
let mut pixels = vec![0u8; nw * nh * 4];
|
||||
if image.pixels.len() < w * h * 4 {
|
||||
return RgbaImage {
|
||||
width: nw as u32,
|
||||
height: nh as u32,
|
||||
pixels,
|
||||
};
|
||||
}
|
||||
for y in 0..nh {
|
||||
let (y0, y1) = ((2 * y).min(h - 1), (2 * y + 1).min(h - 1));
|
||||
let (row0, row1) = (&image.pixels[y0 * w * 4..], &image.pixels[y1 * w * 4..]);
|
||||
let out = &mut pixels[y * nw * 4..(y + 1) * nw * 4];
|
||||
for x in 0..nw {
|
||||
let (x0, x1) = ((2 * x).min(w - 1) * 4, (2 * x + 1).min(w - 1) * 4);
|
||||
for c in 0..4 {
|
||||
let sum = row0[x0 + c] as u16
|
||||
+ row0[x1 + c] as u16
|
||||
+ row1[x0 + c] as u16
|
||||
+ row1[x1 + c] as u16;
|
||||
out[x * 4 + c] = ((sum + 2) / 4) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
RgbaImage {
|
||||
width: nw as u32,
|
||||
height: nh as u32,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -74,6 +105,8 @@ const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
||||
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||
pub enum TextureSource {
|
||||
Ready(RgbaImage),
|
||||
/// Fertige Mipmap-Stufen (siehe [`mip_chain`]), z. B. vom PDF-Render-Thread
|
||||
ReadyMips(Vec<RgbaImage>),
|
||||
/// Wird gerade erzeugt; später erneut fragen
|
||||
Pending,
|
||||
Failed(String),
|
||||
@@ -233,10 +266,16 @@ impl ImageLayer {
|
||||
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,
|
||||
),
|
||||
TextureSource::Ready(image) => {
|
||||
self.upload_texture(device, queue, &key, mip_chain(image))
|
||||
}
|
||||
TextureSource::ReadyMips(levels) if !levels.is_empty() => {
|
||||
self.upload_texture(device, queue, &key, levels)
|
||||
}
|
||||
TextureSource::ReadyMips(_) => {
|
||||
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: leeres Bild");
|
||||
(self.missing_texture(device, queue), 4)
|
||||
}
|
||||
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||
TextureSource::Pending => continue,
|
||||
TextureSource::Failed(err) => {
|
||||
@@ -342,17 +381,19 @@ impl ImageLayer {
|
||||
height: 1,
|
||||
pixels: vec![200, 200, 200, 255],
|
||||
};
|
||||
self.upload_texture(device, queue, MISSING, &gray)
|
||||
self.upload_texture(device, queue, MISSING, vec![gray]).0
|
||||
}
|
||||
|
||||
/// Lädt die Mipmap-Stufen hoch (Stufe 0 = volle Größe); liefert die Bind Group und den
|
||||
/// belegten Grafikspeicher.
|
||||
fn upload_texture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &RgbaImage,
|
||||
) -> wgpu::BindGroup {
|
||||
let levels = mip_chain(image);
|
||||
levels: Vec<RgbaImage>,
|
||||
) -> (wgpu::BindGroup, u64) {
|
||||
let image = &levels[0];
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -389,8 +430,9 @@ impl ImageLayer {
|
||||
},
|
||||
);
|
||||
}
|
||||
let bytes = levels.iter().map(|l| l.pixels.len() as u64).sum();
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Image Texture Bind Group"),
|
||||
layout: &self.texture_layout,
|
||||
entries: &[
|
||||
@@ -403,7 +445,8 @@ impl ImageLayer {
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
(bind_group, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,11 +482,35 @@ mod tests {
|
||||
height: 3,
|
||||
pixels: vec![128; 8 * 3 * 4],
|
||||
};
|
||||
let levels = mip_chain(&base);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_half_size_averages_two_by_two_blocks() {
|
||||
// 3 × 2: links schwarz/weiß übereinander, Mitte grau; die dritte Spalte entfällt
|
||||
let px = |v: u8| [v, v, v, 255];
|
||||
let pixels = [px(0), px(100), px(7), px(255), px(100), px(7)].concat();
|
||||
let base = RgbaImage {
|
||||
width: 3,
|
||||
height: 2,
|
||||
pixels,
|
||||
};
|
||||
let half = half_size(&base);
|
||||
assert_eq!((half.width, half.height), (1, 1));
|
||||
// (0 + 100 + 255 + 100) / 4 = 113,75 → 114
|
||||
assert_eq!(half.pixels, vec![114, 114, 114, 255]);
|
||||
|
||||
// Kantenlänge 1: nur in der anderen Richtung mitteln
|
||||
let column = RgbaImage {
|
||||
width: 1,
|
||||
height: 2,
|
||||
pixels: [px(10), px(20)].concat(),
|
||||
};
|
||||
assert_eq!(half_size(&column).pixels, vec![15, 15, 15, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-19
@@ -596,6 +596,31 @@ fn insert_image_on_page(
|
||||
));
|
||||
}
|
||||
|
||||
/// Trifft ein Finger auf `screen` die Auswahl (Rahmen, Griffe) oder ein Objekt? Dann wählt bzw.
|
||||
/// verschiebt er, sonst verschiebt er das Blatt.
|
||||
fn finger_grabs_selection(
|
||||
screen: (f32, f32),
|
||||
canvas: &CanvasState,
|
||||
page: &ink_notes::document::Page,
|
||||
pixels_per_point: f32,
|
||||
selection_state: &SelectionState,
|
||||
) -> bool {
|
||||
let on_selection = !selection_state.is_empty()
|
||||
&& !matches!(
|
||||
selection_state.hit_test_handles(
|
||||
page,
|
||||
canvas,
|
||||
Vec2::new(screen.0, screen.1),
|
||||
pixels_per_point
|
||||
),
|
||||
SelectionHandleHit::None
|
||||
);
|
||||
on_selection
|
||||
|| canvas
|
||||
.screen_to_page(screen.0, screen.1)
|
||||
.is_some_and(|p| page.find_stroke_at(p, 25.0).is_some())
|
||||
}
|
||||
|
||||
/// Auswahlwerkzeug: Druck auf `screen` (physische Pixel) greift einen Griff, die Auswahl
|
||||
/// oder das Objekt darunter oder beginnt einen Auswahlrahmen. Mit `shift` wird die Auswahl
|
||||
/// erweitert. `true`, wenn etwas begonnen wurde.
|
||||
@@ -916,25 +941,21 @@ fn texture_for(
|
||||
assets: &AssetStore,
|
||||
pdf_renderer: &mut PdfRenderService,
|
||||
) -> TextureSource {
|
||||
let result = match key.contains('#') {
|
||||
true => {
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||
};
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(result) => result,
|
||||
None => return TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
false => assets
|
||||
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
||||
.map_err(|e| e.to_string()),
|
||||
if !key.contains('#') {
|
||||
return match assets.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION) {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err.to_string()),
|
||||
};
|
||||
}
|
||||
let Some((Ok(path), page, max_pixels)) = ink_notes::pdf_doc::parse_page_key(key)
|
||||
.map(|(asset, page, px)| (assets.path_of(asset), page, px))
|
||||
else {
|
||||
return TextureSource::Failed(t!("Ungültiger PDF-Verweis '{key}'", key = key));
|
||||
};
|
||||
match result {
|
||||
Ok(image) => TextureSource::Ready(image),
|
||||
Err(err) => TextureSource::Failed(err),
|
||||
match pdf_renderer.take(key, path, page, max_pixels) {
|
||||
Some(Ok(levels)) => TextureSource::ReadyMips(levels),
|
||||
Some(Err(err)) => TextureSource::Failed(err),
|
||||
None => TextureSource::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2820,7 +2841,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
cursor_screen_pos = (tx, ty);
|
||||
current_pressure = pressure;
|
||||
|
||||
if !ui_state.touch_draw_enabled {
|
||||
// Ohne Finger-Zeichnen verschiebt ein Finger das Blatt – außer bei
|
||||
// Werkzeugen, die nicht zeichnen: Text und Platz schaffen nehmen den
|
||||
// Finger immer, Auswählen, wenn er ein Objekt oder die Auswahl trifft
|
||||
// (Issue #26)
|
||||
let finger_uses_tool = match ui_state.active_tool {
|
||||
ActiveTool::Text | ActiveTool::Space => true,
|
||||
ActiveTool::Select => {
|
||||
focus_page_at!((tx, ty));
|
||||
finger_grabs_selection(
|
||||
(tx, ty),
|
||||
&canvas,
|
||||
notebook.current_page(),
|
||||
egui_ctx.pixels_per_point(),
|
||||
&selection_state,
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if finger_uses_tool {
|
||||
// Halten soll hier kein Kontextmenü öffnen
|
||||
long_press.cancel();
|
||||
}
|
||||
if !ui_state.touch_draw_enabled && !finger_uses_tool {
|
||||
// Standard: 1 Finger scrollt / verschiebt den Canvas (Pan)
|
||||
is_touch_panning = true;
|
||||
touch_pan_last_pos = (tx, ty);
|
||||
@@ -3877,6 +3920,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.set_neighbor_pages(&[]);
|
||||
}
|
||||
let assets = AssetStore::new(&storage_dir);
|
||||
// PDF-Seiten, die nach diesem Bild nicht mehr gebraucht werden (schnelles
|
||||
// Scrollen), verwirft end_frame, bevor sie gerendert werden
|
||||
pdf_renderer.begin_frame();
|
||||
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||
if !canvas.is_waiting_for_images() {
|
||||
@@ -3887,6 +3933,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf_renderer.end_frame();
|
||||
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
|
||||
+239
-29
@@ -1,11 +1,13 @@
|
||||
//! 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.
|
||||
//! erledigt es deshalb in einem Hintergrund-Thread, samt Mipmaps für die Anzeige, und meldet
|
||||
//! fertige Seiten zurück.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||
@@ -104,30 +106,107 @@ struct RenderRequest {
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
/// Wichtigkeit: kleiner zuerst (Reihenfolge der Anfragen im aktuellen Bild)
|
||||
rank: usize,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Render-Auftrags.
|
||||
pub type RenderResult = Result<RgbaImage, String>;
|
||||
/// Wichtigkeit einer vorab gerenderten Seite: nach allen sichtbaren.
|
||||
const PREFETCH_RANK: usize = usize::MAX;
|
||||
|
||||
/// Warteschlange der noch nicht begonnenen Render-Aufträge, geteilt mit dem Render-Thread.
|
||||
#[derive(Default)]
|
||||
struct RenderQueue {
|
||||
requests: Vec<RenderRequest>,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl RenderQueue {
|
||||
/// Fügt einen Auftrag hinzu oder macht einen wartenden wichtiger.
|
||||
fn push(&mut self, request: RenderRequest) {
|
||||
match self.requests.iter_mut().find(|r| r.key == request.key) {
|
||||
Some(queued) => queued.rank = queued.rank.min(request.rank),
|
||||
None => self.requests.push(request),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nimmt den wichtigsten Auftrag heraus.
|
||||
fn pop(&mut self) -> Option<RenderRequest> {
|
||||
let (index, _) = self
|
||||
.requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, r)| r.rank)?;
|
||||
Some(self.requests.swap_remove(index))
|
||||
}
|
||||
|
||||
/// Verwirft Aufträge, für die `rank_of` keinen Rang mehr liefert, und ordnet die übrigen
|
||||
/// neu. Liefert die Schlüssel der verworfenen.
|
||||
fn retain(&mut self, rank_of: impl Fn(&str) -> Option<usize>) -> Vec<String> {
|
||||
let mut dropped = Vec::new();
|
||||
self.requests.retain_mut(|r| match rank_of(&r.key) {
|
||||
Some(rank) => {
|
||||
r.rank = rank;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
dropped.push(r.key.clone());
|
||||
false
|
||||
}
|
||||
});
|
||||
dropped
|
||||
}
|
||||
}
|
||||
|
||||
type SharedQueue = Arc<(Mutex<RenderQueue>, Condvar)>;
|
||||
|
||||
/// Ergebnis eines Render-Auftrags: die Mipmap-Stufen der Seite (siehe
|
||||
/// [`crate::image_layer::mip_chain`]), Stufe 0 in voller Größe.
|
||||
pub type RenderResult = Result<Vec<RgbaImage>, String>;
|
||||
|
||||
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||
///
|
||||
/// Je Bild meldet der Event-Loop zwischen [`Self::begin_frame`] und [`Self::end_frame`] mit
|
||||
/// `take` alle Seiten an, die er gerade braucht (die wichtigste zuerst). Gerendert wird die
|
||||
/// wichtigste wartende Seite zuerst; Seiten, die beim schnellen Scrollen schon wieder aus dem
|
||||
/// Bild sind, verwirft `end_frame`, bevor der Render-Thread sie beginnt.
|
||||
pub struct PdfRenderService {
|
||||
requests: Sender<RenderRequest>,
|
||||
queue: SharedQueue,
|
||||
worker: std::thread::JoinHandle<()>,
|
||||
results: Receiver<(String, RenderResult)>,
|
||||
/// Wartende oder gerade gerenderte Seiten
|
||||
pending: HashSet<String>,
|
||||
ready: HashMap<String, RenderResult>,
|
||||
/// Im aktuellen Bild angeforderte Seiten in ihrer Reihenfolge
|
||||
wanted: Vec<String>,
|
||||
/// Zuletzt vorab angeforderte Seite (bleibt erhalten, bis sie abgeholt wird)
|
||||
prefetched: Option<String>,
|
||||
}
|
||||
|
||||
impl PdfRenderService {
|
||||
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
||||
let queue: SharedQueue = Arc::default();
|
||||
let (res_tx, res_rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
let worker_queue = Arc::clone(&queue);
|
||||
let worker = 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() {
|
||||
loop {
|
||||
let request = {
|
||||
let (lock, ready) = &*worker_queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
if queue.shutdown {
|
||||
return;
|
||||
}
|
||||
if let Some(request) = queue.pop() {
|
||||
break request;
|
||||
}
|
||||
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
};
|
||||
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||
open_pdf = std::fs::read(&request.path)
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -137,36 +216,64 @@ impl PdfRenderService {
|
||||
.map(|pdf| (request.path.clone(), pdf));
|
||||
}
|
||||
let result = match &open_pdf {
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels),
|
||||
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
|
||||
// jeder neu gerenderten Seite (Issue #28)
|
||||
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
|
||||
.map(crate::image_layer::mip_chain),
|
||||
None => Err(t!("PDF kann nicht geöffnet werden").into()),
|
||||
};
|
||||
if res_tx.send((request.key, result)).is_err() {
|
||||
break;
|
||||
return;
|
||||
}
|
||||
on_ready();
|
||||
}
|
||||
})
|
||||
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||
Self {
|
||||
requests: req_tx,
|
||||
queue,
|
||||
worker,
|
||||
results: res_rx,
|
||||
pending: HashSet::new(),
|
||||
ready: HashMap::new(),
|
||||
wanted: Vec::new(),
|
||||
prefetched: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Beginnt die Anfragen eines neuen Bildes (siehe [`Self::end_frame`]).
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.wanted.clear();
|
||||
}
|
||||
|
||||
/// Verwirft wartende Aufträge und fertige Ergebnisse für Seiten, die in diesem Bild nicht
|
||||
/// mehr angefordert wurden (außer der vorab angeforderten), und ordnet die übrigen nach
|
||||
/// der Reihenfolge der Anfragen.
|
||||
pub fn end_frame(&mut self) {
|
||||
let rank_of = |key: &str| {
|
||||
self.wanted
|
||||
.iter()
|
||||
.position(|k| k == key)
|
||||
.or_else(|| (self.prefetched.as_deref() == Some(key)).then_some(PREFETCH_RANK))
|
||||
};
|
||||
let dropped = {
|
||||
let mut queue = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
queue.retain(rank_of)
|
||||
};
|
||||
for key in dropped {
|
||||
self.pending.remove(&key);
|
||||
}
|
||||
self.ready.retain(|key, _| rank_of(key).is_some());
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.prefetched = Some(key.to_string());
|
||||
if self.ready.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
||||
if self.ready.len() >= 2 {
|
||||
self.ready.clear();
|
||||
}
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS);
|
||||
self.request(key, path, page, DISPLAY_RENDER_PIXELS, PREFETCH_RANK);
|
||||
}
|
||||
|
||||
fn collect(&mut self) {
|
||||
@@ -176,21 +283,37 @@ impl PdfRenderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||
fn request(&mut self, key: &str, path: PathBuf, page: usize, max_pixels: u32) -> bool {
|
||||
if !self.pending.insert(key.to_string()) {
|
||||
/// Erteilt den Auftrag bzw. macht einen wartenden wichtiger. `false`, wenn der
|
||||
/// Render-Thread beendet ist.
|
||||
fn request(
|
||||
&mut self,
|
||||
key: &str,
|
||||
path: PathBuf,
|
||||
page: usize,
|
||||
max_pixels: u32,
|
||||
rank: usize,
|
||||
) -> bool {
|
||||
if self.worker.is_finished() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
let (lock, ready) = &*self.queue;
|
||||
let mut queue = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Steht ein angefragter Auftrag nicht mehr in der Warteschlange, rendert ihn der
|
||||
// Thread gerade: nicht doppelt erteilen
|
||||
let waiting = queue.requests.iter().any(|r| r.key == key);
|
||||
if self.pending.contains(key) && !waiting {
|
||||
return true;
|
||||
}
|
||||
let request = RenderRequest {
|
||||
self.pending.insert(key.to_string());
|
||||
queue.push(RenderRequest {
|
||||
key: key.to_string(),
|
||||
path,
|
||||
page,
|
||||
max_pixels,
|
||||
};
|
||||
if self.requests.send(request).is_err() {
|
||||
self.pending.remove(key);
|
||||
return false;
|
||||
}
|
||||
rank,
|
||||
});
|
||||
ready.notify_one();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -204,16 +327,29 @@ impl PdfRenderService {
|
||||
max_pixels: u32,
|
||||
) -> Option<RenderResult> {
|
||||
self.collect();
|
||||
if self.prefetched.as_deref() == Some(key) {
|
||||
self.prefetched = None;
|
||||
}
|
||||
if let Some(result) = self.ready.remove(key) {
|
||||
return Some(result);
|
||||
}
|
||||
if !self.request(key, path, page, max_pixels) {
|
||||
let rank = self.wanted.len();
|
||||
self.wanted.push(key.to_string());
|
||||
if !self.request(key, path, page, max_pixels, rank) {
|
||||
return Some(Err(t!("PDF-Render-Thread beendet").into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PdfRenderService {
|
||||
fn drop(&mut self) {
|
||||
let (lock, ready) = &*self.queue;
|
||||
lock.lock().unwrap_or_else(|e| e.into_inner()).shutdown = true;
|
||||
ready.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
@@ -303,8 +439,12 @@ pub(crate) mod tests {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap();
|
||||
let image = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||
let levels = service.take("a#0", path.clone(), 0, px).unwrap().unwrap();
|
||||
assert_eq!(levels[0].width, DISPLAY_RENDER_PIXELS);
|
||||
assert_eq!(
|
||||
(levels.last().unwrap().width, levels.last().unwrap().height),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// Vorab rendern: danach sofort verfügbar
|
||||
service.prefetch("a#0", path.clone(), 0);
|
||||
@@ -314,6 +454,76 @@ pub(crate) mod tests {
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
fn request(key: &str, rank: usize) -> RenderRequest {
|
||||
RenderRequest {
|
||||
key: key.into(),
|
||||
path: PathBuf::new(),
|
||||
page: 0,
|
||||
max_pixels: 1,
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_queue_priorities_and_stale_requests() {
|
||||
let mut queue = RenderQueue::default();
|
||||
queue.push(request("a", 2));
|
||||
queue.push(request("b", 0));
|
||||
queue.push(request("c", 1));
|
||||
// Erneute Anfrage macht einen wartenden Auftrag wichtiger, ohne ihn zu verdoppeln
|
||||
queue.push(request("a", 0));
|
||||
assert_eq!(queue.requests.len(), 3);
|
||||
assert_eq!(queue.pop().map(|r| r.key), Some("a".into()));
|
||||
|
||||
// Nach dem Scrollen wird nur noch „c“ gebraucht: „b“ fällt weg
|
||||
let dropped = queue.retain(|key| (key == "c").then_some(0));
|
||||
assert_eq!(dropped, vec!["b".to_string()]);
|
||||
assert_eq!(queue.pop().map(|r| r.key), Some("c".into()));
|
||||
assert!(queue.pop().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_frame_drops_unwanted_results_but_keeps_prefetch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("a.pdf");
|
||||
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0), (400.0, 300.0, 0)])).unwrap();
|
||||
let (wake_tx, wake_rx) = mpsc::channel();
|
||||
let mut service = PdfRenderService::new(move || {
|
||||
let _ = wake_tx.send(());
|
||||
});
|
||||
let px = DISPLAY_RENDER_PIXELS;
|
||||
let wait = || {
|
||||
wake_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Bild 1 braucht Seite 1; vorab gerendert wird Seite 2
|
||||
service.begin_frame();
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
service.prefetch("a#1", path.clone(), 1);
|
||||
service.end_frame();
|
||||
wait();
|
||||
wait();
|
||||
|
||||
// Bild 2 braucht keine Seite mehr (weggescrollt): Seite 1 wird verworfen,
|
||||
// die vorab gerenderte Seite 2 bleibt
|
||||
service.begin_frame();
|
||||
service.collect();
|
||||
service.end_frame();
|
||||
assert!(!service.ready.contains_key("a#0"));
|
||||
assert!(service.ready.contains_key("a#1"));
|
||||
assert!(service.pending.is_empty());
|
||||
|
||||
service.begin_frame();
|
||||
assert!(service.take("a#1", path.clone(), 1, px).unwrap().is_ok());
|
||||
// Seite 1 wird bei Bedarf neu gerendert
|
||||
assert!(service.take("a#0", path.clone(), 0, px).is_none());
|
||||
service.end_frame();
|
||||
wait();
|
||||
assert!(service.take("a#0", path, 0, px).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_page_key() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -4281,6 +4281,18 @@ pub fn render_ui(
|
||||
ui.label(egui::RichText::new(format!("({})", runtime_name)).color(egui::Color32::from_gray(150)));
|
||||
});
|
||||
|
||||
if !updater.runtime().self_update_allowed() {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new(t!(
|
||||
"Diese Version wird über die Software-Verwaltung aktualisiert \
|
||||
(z. B. GNOME Software oder „flatpak update“)."
|
||||
))
|
||||
.color(egui::Color32::from_gray(210)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(t!("Server:")).color(egui::Color32::from_gray(180)));
|
||||
ui.label(egui::RichText::new(format!("{}/{}/{}", updater.config().server_url, updater.config().repo_owner, updater.config().repo_name)).color(egui::Color32::from_gray(210)));
|
||||
@@ -4446,7 +4458,8 @@ pub fn render_ui(
|
||||
let action_label = match updater.runtime() {
|
||||
crate::updater::RuntimeEnvironment::AppImage { .. } => t!("🚀 AppImage aktualisieren & ausführen"),
|
||||
crate::updater::RuntimeEnvironment::DebPackage { .. } => t!("📦 Paket mit Software-Center öffnen / installieren"),
|
||||
crate::updater::RuntimeEnvironment::PortableOrDev { .. } => t!("📂 Im Dateimanager anzeigen"),
|
||||
crate::updater::RuntimeEnvironment::PortableOrDev { .. }
|
||||
| crate::updater::RuntimeEnvironment::Flatpak => t!("📂 Im Dateimanager anzeigen"),
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -118,11 +118,20 @@ pub enum RuntimeEnvironment {
|
||||
DebPackage { binary_path: PathBuf },
|
||||
/// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release)
|
||||
PortableOrDev { binary_path: PathBuf },
|
||||
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
|
||||
/// selbst nichts herunter (Vorgabe von Flathub)
|
||||
Flatpak,
|
||||
}
|
||||
|
||||
impl RuntimeEnvironment {
|
||||
/// Erkennt die aktuelle Laufzeitumgebung anhand der Umgebung und des Binärpfads.
|
||||
pub fn detect() -> Self {
|
||||
if is_flatpak(
|
||||
std::env::var("FLATPAK_ID").ok().as_deref(),
|
||||
Path::new("/.flatpak-info").exists(),
|
||||
) {
|
||||
return RuntimeEnvironment::Flatpak;
|
||||
}
|
||||
let appimage_var = std::env::var("APPIMAGE").ok();
|
||||
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
|
||||
Self::detect_from(appimage_var.as_deref(), ¤t_exe)
|
||||
@@ -156,8 +165,20 @@ impl RuntimeEnvironment {
|
||||
RuntimeEnvironment::AppImage { .. } => t!("AppImage (Portabel)"),
|
||||
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
|
||||
RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
|
||||
RuntimeEnvironment::Flatpak => "Flatpak",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob ink-notes selbst nach Updates suchen und sie installieren darf
|
||||
pub fn self_update_allowed(&self) -> bool {
|
||||
!matches!(self, RuntimeEnvironment::Flatpak)
|
||||
}
|
||||
}
|
||||
|
||||
/// Läuft ink-notes in einer Flatpak-Sandbox (`FLATPAK_ID` gesetzt oder `/.flatpak-info`
|
||||
/// vorhanden)?
|
||||
pub fn is_flatpak(flatpak_id: Option<&str>, info_file_exists: bool) -> bool {
|
||||
flatpak_id.is_some_and(|id| !id.trim().is_empty()) || info_file_exists
|
||||
}
|
||||
|
||||
/// Bereinigt und validiert einen Asset-Dateinamen gegen Path Traversal Angriffe.
|
||||
@@ -578,6 +599,9 @@ impl UpdaterController {
|
||||
|
||||
/// Fragt im Hintergrund die Gitea-API ab.
|
||||
pub fn check_for_updates(&mut self) {
|
||||
if !self.runtime.self_update_allowed() {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
self.status,
|
||||
UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }
|
||||
@@ -887,6 +911,10 @@ impl UpdaterController {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RuntimeEnvironment::Flatpak => Err(t!(
|
||||
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
|
||||
)
|
||||
.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,6 +1102,19 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatpak_detection_disables_self_update() {
|
||||
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
|
||||
assert!(is_flatpak(None, true));
|
||||
assert!(!is_flatpak(Some(" "), false));
|
||||
assert!(!is_flatpak(None, false));
|
||||
assert!(!RuntimeEnvironment::Flatpak.self_update_allowed());
|
||||
assert!(
|
||||
RuntimeEnvironment::detect_from(None, Path::new("/usr/bin/ink-notes"))
|
||||
.self_update_allowed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_https_enforcement() {
|
||||
let config = UpdaterConfig {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Das Flatpak baut ohne Netz aus `packaging/flatpak/cargo-sources.json`; jede Abhängigkeit
|
||||
//! aus `Cargo.lock` muss dort stehen, sonst schlägt der Flatpak-Build fehl.
|
||||
|
||||
#[test]
|
||||
fn cargo_sources_cover_cargo_lock() {
|
||||
let lock = include_str!("../Cargo.lock");
|
||||
let sources = include_str!("../packaging/flatpak/cargo-sources.json");
|
||||
let mut missing = Vec::new();
|
||||
for package in lock.split("[[package]]").skip(1) {
|
||||
let field = |key: &str| {
|
||||
package.lines().find_map(|line| {
|
||||
line.strip_prefix(key)
|
||||
.map(|rest| rest.trim().trim_matches('"').to_string())
|
||||
})
|
||||
};
|
||||
// Nur Pakete aus crates.io (das eigene Paket hat keine Quelle)
|
||||
if field("source = ").is_none() {
|
||||
continue;
|
||||
}
|
||||
let (Some(name), Some(version)) = (field("name = "), field("version = ")) else {
|
||||
continue;
|
||||
};
|
||||
let crate_file = format!("/{name}-{version}.crate\"");
|
||||
if !sources.contains(&crate_file) {
|
||||
missing.push(format!("{name} {version}"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"cargo-sources.json ist veraltet (flatpak-cargo-generator neu ausführen), es fehlen: {}",
|
||||
missing.join(", ")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user