Compare commits

..
7 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 2a9a67d399 chore(release): bump version to 0.41.0
Build & Publish Release / Android APK (push) Successful in 6m23s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:48:54 +01:00
haraldandClaude Opus 5.5 307c803246 feat(math): offer the result next to a handwritten = (Issue #46, stage 3)
New module math_assist.rs: when the last two strokes form an = and the pen
rests for 500 ms, the strokes left of it (in a band around the =, up to a
wide gap) are recognized and computed. The suggestion appears next to
the = and shows what was read, e.g. "2⁴ = 16"; tapping it with the pen
or clicking inserts the result as a text box in the ink color and as high
as the handwriting, as one undo step. The chip is not interactive for
egui, so fingers and palms pass through. Any new stroke drops it. In a
chain like x = 2 · 3 = the part after the previous = is computed.

Off by default: ☰ → Input → "Compute formulas after =", saved in
ui.json. math::display writes the recognized expression neatly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:45:00 +01:00
haraldandClaude Opus 5.5 d16cd2db72 feat(math): recognize handwritten expressions (Issue #46, stage 2)
New module math_ink.rs turns the strokes of an expression into text for
math::solve. Consecutive overlapping strokes are grouped into symbols,
each symbol is classified with the $P point-cloud recognizer (plus the
turning angle and a small stroke-count penalty) against built-in
templates for digits, + ( ) / x y a b, and the structure comes from the
positions: stacked lines are =, stacked dots :, lines with symbols above
and below are fraction bars, low dots between digits decimal commas and
raised symbols exponents. Own templates can be added for stage 4.

Tested with synthetic handwriting (slanted, rotated, warped); not used by
the UI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:27:25 +01:00
haraldandClaude Opus 5.5 cd490dd9a7 feat(math): exact evaluation of handwritten expressions (Issue #46, stage 1)
New module math.rs: solve() reads a recognised expression such as 2^4,
2,5 · 3 or (x + 3)² and returns its result as text (16, 7,5,
x² + 6x + 9). Own parser with exact fractions and polynomial expansion
instead of evalexpr, as implicit multiplication and expanding need it
anyway. Not used by the UI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:34:32 +01:00
haraldandClaude Opus 5.5 8af10fe542 chore(release): bump version to 0.40.0
Build & Publish Release / Android APK (push) Successful in 6m46s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 12m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:32:30 +01:00
haraldandClaude Opus 5.5 6694d79f16 ci(android): ship the APK with every release (Issue #45)
The release job now waits for the android job, takes its APK, lists it in
SHA256SUMS.txt and uploads it as a release asset. The android job is no
longer optional. README describes installing the APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:32:30 +01:00
haraldandClaude Opus 5.5 ad1dab9161 chore: ignore Android keystores
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:27:52 +01:00
19 changed files with 2755 additions and 23 deletions

No files matched your search

+13 -6
View File
@@ -13,10 +13,18 @@ jobs:
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
runs-on: ubuntu-latest
# The APK from the android job goes into the release and its SHA256SUMS.txt
needs: android
steps:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
- name: Fetch Android APK
uses: actions/download-artifact@v4.3.0
with:
name: ink-notes-android-apk
path: target
- name: Install System Packaging Tools
run: |
export DEBIAN_FRONTEND=noninteractive
@@ -63,13 +71,14 @@ jobs:
cargo clippy --all-targets -- -D warnings
cargo test
- name: Build Release Artifacts (.deb, .AppImage, .exe, SHA256SUMS.txt + .minisig)
- name: Build Release Artifacts (.deb, .AppImage, .exe, .apk, SHA256SUMS.txt + .minisig)
run: |
chmod +x scripts/*.sh
./scripts/build-release.sh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
INK_NOTES_REQUIRE_SIGNATURE: "1"
INK_NOTES_REQUIRE_APK: "1"
- name: Publish Release to Gitea
run: |
@@ -81,11 +90,9 @@ jobs:
REPO_NAME: ${{ github.event.repository.name }}
android:
# Experimental (Issue #45): builds the APK and keeps it as a workflow artifact. It is not
# part of the release yet, and a failure here does not stop the release above.
name: Android APK (experimental)
# Builds the signed APK (Issue #45) and hands it to the release job as a workflow artifact
name: Android APK
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout Repository
uses: actions/checkout@v4.2.2
@@ -131,7 +138,7 @@ jobs:
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
- name: Keep APK as Artifact
- name: Hand APK to the Release Job
uses: actions/upload-artifact@v4.6.2
with:
name: ink-notes-android-apk
+4
View File
@@ -1,3 +1,7 @@
/target
*.pdf
export.pdf
# Android release signing key, never commit it
*.keystore
*.jks
Generated
+1 -1
View File
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.39.3"
version = "0.41.0"
dependencies = [
"ab_glyph",
"android_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.39.3"
version = "0.41.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+13 -3
View File
@@ -13,7 +13,7 @@
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++, Rnote und Saber importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). Eine Windows-Version gibt es als experimentelles portables Programm.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). Eine Windows-Version gibt es als experimentelles portables Programm, eine Android-Version als APK.
Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemsprache, umstellen lässt sie sich im ☰-Menü unter „Sprache / Language“.
@@ -37,6 +37,11 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- Erkannt werden Linien, Pfeile, Kreise, Ellipsen, Rechtecke, Dreiecke, Rauten, Vielecke, Koordinatensysteme (L-Form), Achsenkreuze (ein „+“ in einem Zug oder zwei gekreuzte Geraden, Pfeilspitzen an allen vier Enden) und Oktaeder (Skizze als 3D-Körper).
- Die Erkennung arbeitet rein geometrisch (Bogenlänge, Douglas-Peucker, Kreis-Fit), ohne Machine Learning. Nicht alles wird erkannt – was nicht passt, bleibt Handschrift.
### Formeln ausrechnen (experimentell)
- Einschalten im ☰-Menü unter Eingabe („Formeln nach „=“ ausrechnen“). Nach einem handgeschriebenen `=` und einer kurzen Pause erscheint daneben das Ergebnis samt dem Gelesenen, z. B. `2⁴ = 16` oder `(x + 3)² = x² + 6x + 9`; Antippen mit dem Stift oder Klicken fügt es als Textfeld in der Stiftfarbe und in der Höhe der Handschrift ein (Rückgängig entfernt es). Finger und Handballen lösen nichts aus.
- Gelesen werden Ziffern, `+ − · : /`, Klammern, Hochzahlen (hochgestellt geschrieben), Bruchstriche, Dezimalkommas und die Variablen `x`, `y`, `a`, `b`. Zahlen werden exakt als Brüche gerechnet, Ausdrücke mit Variablen ausmultipliziert. Vor der Formel etwas Abstand lassen, damit weiter links Geschriebenes nicht mitgelesen wird; in einer Kette wie `x = 2 · 3 =` wird der Teil nach dem vorigen `=` gerechnet.
- Die Zeichen werden mit Punktwolken-Vorlagen ($P) erkannt, ohne Machine Learning und offline. Zeigt der Vorschlag ein falsches Zeichen, ihn einfach ignorieren.
### Auswählen und Bearbeiten
- **Auswahlwerkzeug** (`V`): antippen oder Rahmen aufziehen, dann verschieben, an den Ecken skalieren, am Griff drehen oder mit `R` um 90° drehen. Funktioniert mit Maus, Finger und Stift.
- Auswahl über die Palette **umfärben**, **kopieren/einfügen** (`Strg + C/V`), **löschen** (`Entf`).
@@ -87,7 +92,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
## Installation
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb`, `.AppImage` und für Windows als `.exe` (x86_64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
Pakete gibt es unter [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) als `.deb`, `.AppImage`, für Windows als `.exe` (x86_64) und für Android als `.apk` (arm64). `<version>` steht für die Versionsnummer, z. B. `0.11.0`.
```bash
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
@@ -104,6 +109,8 @@ Hinweise:
**Windows (experimentell):** `ink-notes_<version>_x86_64.exe` ist ein portables Programm für Windows 10/11 – ohne Installation einfach starten (Windows SmartScreen fragt evtl. einmal nach, da das Programm nicht signiert ist). Stifteingabe mit Druck läuft über Windows Ink (noch nicht auf echter Hardware getestet). Notizbücher und Einstellungen liegen in `%APPDATA%\ink-notes`. Updates ersetzen die `.exe` an Ort und Stelle und gelten ab dem nächsten Start.
**Android:** `ink-notes_<version>_arm64.apk` läuft ab Android 8 (64-Bit-ARM). Auf dem Gerät herunterladen und öffnen; Android fragt einmal, ob Browser oder Dateimanager Apps installieren dürfen. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Notizbücher liegen im eigenen Speicher der App und werden beim Deinstallieren gelöscht – vorher exportieren, was bleiben soll. Ein Update in der App gibt es nicht: die neue APK über die alte installieren, die Notizbücher bleiben erhalten.
---
## Tastenkürzel
@@ -156,7 +163,7 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
Andere Systeme:
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Ohne SDK auf dem Rechner baut `scripts/build-apk-container.sh` sie in einem Container (podman oder docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installiert die festen SDK-/NDK-Versionen. Die CI baut die APK als Workflow-Artefakt (noch nicht im Release); ein Release-Schlüssel gehört in die Secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur, Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Tastaturtext und Dateidialog kommen von der kleinen Java-Activity `packaging/android/java/…/InkActivity.java` (zum Bauen ist ein JDK nötig).
- **Android** (Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Ohne SDK auf dem Rechner baut `scripts/build-apk-container.sh` sie in einem Container (podman oder docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installiert die festen SDK-/NDK-Versionen. Der CI-Job `android` baut die APK zu jedem Release und legt sie ins Release und in `SHA256SUMS.txt`; der Release-Schlüssel gehört in die Secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur, Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Tastaturtext und Dateidialog kommen von der kleinen Java-Activity `packaging/android/java/…/InkActivity.java` (zum Bauen ist ein JDK nötig).
### Projektstruktur
@@ -189,6 +196,9 @@ src/
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
├── library.rs # Ordner der Notizbücher (library.json)
├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46)
├── math_ink.rs # Erkennung handgeschriebener Formeln ($P-Punktwolken)
├── math_assist.rs # Ergebnis neben einem handgeschriebenen „=“ (Auslöser, Vorschlag)
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
+13 -3
View File
@@ -13,7 +13,7 @@
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++, Rnote and Saber notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11). A Windows version is available as an experimental portable program.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11). A Windows version is available as an experimental portable program, and an Android version as an APK.
The interface is available in English and German; ink-notes follows the system language, and you can switch it in the ☰ menu under "Sprache / Language".
@@ -37,6 +37,11 @@ The interface is available in English and German; ink-notes follows the system l
- Recognized shapes: lines, arrows, circles, ellipses, rectangles, triangles, rhombuses, polygons, coordinate systems (L shape), axis crosses (a "+" in one stroke or two crossing straight lines, arrowheads on all four ends) and octahedra (sketch as a 3D solid).
- Recognition is purely geometric (arc length, Douglas-Peucker, circle fitting), without machine learning. Not everything is recognized – whatever does not fit stays handwriting.
### Computing formulas (experimental)
- Turn on in the ☰ menu under input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as a text box in the ink color, as high as the handwriting (undo removes it). Fingers and palms do not trigger it.
- Read are digits, `+ − · : /`, parentheses, exponents (written raised), fraction bars, decimal commas and the variables `x`, `y`, `a`, `b`. Numbers are computed exactly as fractions, expressions with variables are multiplied out. Leave some space before the formula, so that writing further to the left is not read with it; in a chain such as `x = 2 · 3 =` the part after the previous `=` is computed.
- The symbols are recognized with point-cloud templates ($P), without machine learning and offline. If the suggestion shows a wrong symbol, just ignore it.
### Selecting and editing
- **Selection tool** (`V`): tap or drag a frame, then move, scale by the corners, rotate by the handle, or rotate by 90° with `R`. Works with mouse, finger and pen.
- **Recolor** the selection with the palette, **copy/paste** (`Ctrl + C/V`), **delete** (`Del`).
@@ -87,7 +92,7 @@ The interface is available in English and German; ink-notes follows the system l
## Installation
Packages are available under [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) as `.deb`, `.AppImage` and for Windows as `.exe` (x86_64). `<version>` stands for the version number, e.g. `0.30.2`.
Packages are available under [Releases](https://gitea.pansi.eu/harald/ink-notes/releases) as `.deb`, `.AppImage`, for Windows as `.exe` (x86_64) and for Android as `.apk` (arm64). `<version>` stands for the version number, e.g. `0.30.2`.
```bash
# Debian package (Zorin OS 18 / Ubuntu 24.04)
@@ -104,6 +109,8 @@ Notes:
**Windows (experimental):** `ink-notes_<version>_x86_64.exe` is a portable program for Windows 10/11 – no installation, just start it (Windows SmartScreen may ask once, as the program is not signed). Pen input with pressure uses Windows Ink (not yet tested on real hardware). Notebooks and settings are stored in `%APPDATA%\ink-notes`. Updates replace the `.exe` in place and take effect at the next start.
**Android:** `ink-notes_<version>_arm64.apk` runs on Android 8 and newer (64-bit ARM). Download it on the device and open it; Android asks once to allow installing apps from the browser or file manager. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks are stored in the app's own storage and are removed when the app is uninstalled – export what you want to keep first. There is no in-app update: install the new APK over the old one, the notebooks stay.
---
## Keyboard shortcuts
@@ -156,7 +163,7 @@ CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D
Other systems:
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Without SDK on the host, `scripts/build-apk-container.sh` builds it in a container (podman or docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installs the fixed SDK/NDK versions. The CI builds the APK as a workflow artifact (not yet part of the release); a release key goes into the secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). The small Java activity `packaging/android/java/…/InkActivity.java` (needs a JDK to build) provides keyboard text and the file dialog.
- **Android** (Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Without SDK on the host, `scripts/build-apk-container.sh` builds it in a container (podman or docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installs the fixed SDK/NDK versions. The CI job `android` builds the APK for every release and adds it to the release and to `SHA256SUMS.txt`; the release key goes into the secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). The small Java activity `packaging/android/java/…/InkActivity.java` (needs a JDK to build) provides keyboard text and the file dialog.
New code comments and commit messages are written in English; older code and history still contain German.
@@ -191,6 +198,9 @@ src/
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
├── disk_watch.rs # Taking over changes from other devices (sync clients)
├── library.rs # Notebook folders (library.json)
├── math.rs # Exact evaluation of handwritten math (Issue #46)
├── math_ink.rs # Recognition of handwritten math ($P point clouds)
├── math_assist.rs # Result next to a handwritten "=" (trigger, suggestion)
├── autosave.rs # Saving in the background
├── export.rs # PDF export
├── page_export.rs # Page as PNG/SVG, selection as image
+10
View File
@@ -84,6 +84,16 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.41.0" date="2026-10-28">
<description>
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
</description>
</release>
<release version="0.40.0" date="2026-10-28">
<description>
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
</description>
</release>
<release version="0.39.3" date="2026-10-27">
<description>
<p>Much lower battery use: the app no longer redraws the window continuously while nothing happens.</p>
+10
View File
@@ -84,6 +84,16 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.41.0" date="2026-10-28">
<description>
<p>Experimental: computes handwritten formulas after an equals sign and offers the result next to it (turn on in the menu under input).</p>
</description>
</release>
<release version="0.40.0" date="2026-10-28">
<description>
<p>New Android version: a signed APK for 64-bit ARM devices with pen, touch, soft keyboard, file import and exports to Downloads.</p>
</description>
</release>
<release version="0.39.3" date="2026-10-27">
<description>
<p>Much lower battery use: the app no longer redraws the window continuously while nothing happens.</p>
+2
View File
@@ -3,6 +3,8 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Computing formulas (experimental)**: Turn on in ☰ → Input ("Compute formulas after “=”"). After a handwritten `=` and a short pause, the result appears next to it together with what was read, e.g. `2⁴ = 16` or `(x + 3)² = x² + 6x + 9`; tapping it with the pen or clicking inserts it as text in the ink color and as high as the handwriting. Digits, `+ − · : /`, parentheses, exponents, fraction bars, decimal commas and `x`, `y`, `a`, `b` are read; numbers are computed exactly, expressions with variables multiplied out. Recognition runs offline without machine learning – feedback with real handwriting welcome (Issue #46)
- **Android**: `ink-notes_<version>_arm64.apk` brings ink-notes to Android 8 and newer (64-bit ARM) – download it on the device and open it to install. The pen is told apart from fingers (pressure, pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). Notebooks live in the app's own storage and are removed when uninstalling; updates are installed by opening the new APK. Nothing changes on Linux and Windows
- **Battery drain fixed**: ink-notes redrew its window at full frame rate even when nothing happened, which kept the app and the desktop (e.g. gnome-shell) busy and drained the battery. It now only draws when something changes; idle CPU load drops to practically zero. Also applies to Windows. Internally, the Android version now follows the app lifecycle and can be packed into an APK (not part of the release yet)
- **PDF export on current lopdf**: The PDF export uses its own small PDF writer on top of lopdf 0.46 instead of the outdated printpdf 0.7 (which pinned lopdf 0.31). Exported PDFs look the same – layers, searchable text, images, highlighters and bookmarks stay as before
- **Windows mouse fix**: The mouse works normally again in the Windows version – Windows Ink reported it as a pen, so clicks and moves went the wrong way. Nothing changes on Linux
+12 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
# Erstellt .deb, .AppImage, Windows-.exe, SHA256-Prüfsummen und deren Minisign-Signatur in target/
# Erstellt .deb, .AppImage, Windows-.exe, (mit vorhandener APK) SHA256-Prüfsummen und deren Minisign-Signatur in target/
# ==============================================================================
set -euo pipefail
@@ -14,6 +14,8 @@ VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
DEB_NAME="ink-notes_${VERSION}_amd64.deb"
APPIMAGE_NAME="ink-notes_${VERSION}_x86_64.AppImage"
EXE_NAME="ink-notes_${VERSION}_x86_64.exe"
# Built separately (scripts/package-android.sh, CI job android); listed in the checksums if present
APK_NAME="ink-notes_${VERSION}_arm64.apk"
CHECKSUM_FILE="SHA256SUMS.txt"
echo "======================================================================"
@@ -32,7 +34,14 @@ echo "======================================================================"
# 4. Checksums berechnen
echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" > "${CHECKSUM_FILE}"
RELEASE_FILES=("${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}")
if [ -f "${APK_NAME}" ]; then
RELEASE_FILES+=("${APK_NAME}")
elif [ "${INK_NOTES_REQUIRE_APK:-0}" = "1" ]; then
echo "FEHLER: target/${APK_NAME} fehlt (INK_NOTES_REQUIRE_APK=1)."
exit 1
fi
sha256sum "${RELEASE_FILES[@]}" > "${CHECKSUM_FILE}"
# 5. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
@@ -77,7 +86,7 @@ fi
echo "======================================================================"
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
echo "======================================================================"
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" "${CHECKSUM_FILE}"
ls -lh "${RELEASE_FILES[@]}" "${CHECKSUM_FILE}"
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
echo ""
echo "Inhalt von target/${CHECKSUM_FILE}:"
+5 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# ==============================================================================
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
# Lädt .deb, .AppImage, .exe, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# Lädt .deb, .AppImage, .exe, .apk (falls gebaut), SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ==============================================================================
set -euo pipefail
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
EXE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.exe"
APK_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_arm64.apk"
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
@@ -238,6 +239,9 @@ except Exception:
upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}"
upload_asset "${EXE_FILE}"
if [ -f "${APK_FILE}" ]; then
upload_asset "${APK_FILE}"
fi
upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
+115 -5
View File
@@ -335,7 +335,7 @@ fn commit_stroke_to_notebook(
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
ui_state: &UiState,
ui_state: &mut UiState,
window: &Window,
snapped_shape: Option<RecognizedShape>,
) {
@@ -357,6 +357,7 @@ fn commit_stroke_to_notebook(
let page = notebook.current_page_mut();
page.replace_stroke(previous_id, stroke);
canvas.sync_page(page);
ui_state.math.stroke_committed(page, Instant::now());
autosave.mark_dirty(notebook, manifest);
window.request_redraw();
return;
@@ -383,6 +384,8 @@ fn commit_stroke_to_notebook(
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
// An `=` may have been completed (Issue #46)
ui_state.math.stroke_committed(page, Instant::now());
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
// Durchgehende Ansicht: unten auf der letzten Seite geht es auf einer neuen weiter
@@ -915,6 +918,54 @@ fn insert_text_on_page(
ui_state.set_export_message(t!("Text eingefügt – mit dem Text-Werkzeug (T) änderbar"));
}
/// Inserts the result of a math suggestion as a text box next to its `=`, in the ink color
/// and as high as the handwriting, as one undo step (Issue #46)
fn insert_math_result(
suggestion: &crate::math_assist::Suggestion,
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
) {
let page = notebook.current_page();
if page.id != suggestion.page_id || page.is_readonly() {
return;
}
let font = crate::text::TextFont::Sans;
let ascent_per_em = crate::text::TextBox::new(String::new(), 1.0, font, 1.0)
.layout()
.ascent;
let (origin, size) = suggestion.text_placement(ascent_per_em);
let mut edit = crate::text::TextEditing::new_at(
page.id,
origin,
page.text_right_edge(origin.x),
size,
font,
suggestion.color,
);
edit.origin = origin;
edit.text.content = suggestion.result.clone();
// As wide as the result, so that it does not wrap near the edge of the page
edit.text.wrap_width = edit
.text
.layout()
.lines
.iter()
.map(|line| line.width)
.fold(0.0, f32::max)
+ size;
let id = notebook.next_stroke_id();
let Some(stroke) = edit.to_stroke(id) else {
return;
};
let page = notebook.current_page_mut();
page.add_stroke(stroke);
canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_dirty(notebook, manifest);
}
/// Beendet eine laufende Textbearbeitung: übernimmt das Feld (neu, geändert oder – wenn
/// geleert – entfernt) als einen Undo-Schritt auf seiner Seite.
fn finish_text_edit(
@@ -1419,6 +1470,7 @@ pub fn run_with(
ui_state.pen_presets = ui_settings.pen_presets;
ui_state.stylus_buttons = ui_settings.stylus_buttons.clone();
ui_state.set_eraser_radius_mm(ui_settings.eraser_radius_mm);
ui_state.math.set_enabled(ui_settings.math_assist);
ui_state.select_pen(ui_settings.active_pen);
ui_state.highlighter_colors = ui_settings.highlighter_colors;
ui_state.select_highlighter_color(ui_settings.active_highlighter);
@@ -1864,7 +1916,7 @@ pub fn run_with(
&mut notebook,
&mut manifest,
&mut autosave,
&ui_state,
&mut ui_state,
&window,
stroke_snapped_shape.take(),
);
@@ -2048,6 +2100,26 @@ pub fn run_with(
stylus_tool_id = Some(tool);
let (sx, sy) = stylus_screen_pos;
// Tapping a math suggestion inserts its result (Issue #46)
if let Some(suggestion) = (!ui_state.view_mode)
.then(|| {
ui_state.math.take_hit(egui::pos2(sx / scale, sy / scale))
})
.flatten()
{
insert_math_result(
&suggestion,
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
);
// The rest of this tap goes nowhere
stylus_ui_capture = true;
window.request_redraw();
continue;
}
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
{
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
@@ -2275,7 +2347,7 @@ pub fn run_with(
&mut notebook,
&mut manifest,
&mut autosave,
&ui_state,
&mut ui_state,
&window,
snapped_shape,
);
@@ -2351,6 +2423,16 @@ pub fn run_with(
window.request_redraw();
}
// Pause after an `=` is over: compute the expression (Issue #46)
if ui_state.math.update(
notebook.current_page(),
Instant::now(),
is_drawing || is_stylus_drawing,
crate::i18n::language() == crate::i18n::Language::German,
) {
window.request_redraw();
}
// Nachgleiten nach dem Wischen
if let Some(delta) = fling.step(Instant::now()) {
canvas.pan(delta.x, delta.y);
@@ -2377,6 +2459,7 @@ pub fn run_with(
} else if let Some(at) = [
egui_repaint_at,
long_press.deadline(),
ui_state.math.deadline(),
Some(disk_watch.deadline()),
// The updater thread does not wake the loop: look for its results now and then
matches!(
@@ -3176,6 +3259,25 @@ pub fn run_with(
return;
}
// Clicking a math suggestion inserts its result (Issue #46)
if button == MouseButton::Left && !ui_state.view_mode {
let (sx, sy) = cursor_screen_pos;
let scale = window.scale_factor() as f32;
if let Some(suggestion) =
ui_state.math.take_hit(egui::pos2(sx / scale, sy / scale))
{
insert_math_result(
&suggestion,
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
);
window.request_redraw();
return;
}
}
fling.stop();
// 1. Pan via mittlere Maustaste oder Leertaste + Linksklick
// In view-only mode the left and right buttons move the page too (Issue #35)
@@ -3380,7 +3482,7 @@ pub fn run_with(
&mut notebook,
&mut manifest,
&mut autosave,
&ui_state,
&mut ui_state,
&window,
snapped_shape,
);
@@ -3958,7 +4060,7 @@ pub fn run_with(
&mut notebook,
&mut manifest,
&mut autosave,
&ui_state,
&mut ui_state,
&window,
snapped_shape,
);
@@ -5187,6 +5289,14 @@ pub fn run_with(
}
}
}
UiAction::SetMathAssist(on) => {
ui_settings.math_assist = on;
save_ui_settings(
&ui_settings,
ui_settings_path.as_deref(),
&mut ui_state,
);
}
UiAction::SetSidebarSide(side) => {
ui_settings.sidebar_side = side;
save_ui_settings(
+2
View File
@@ -107,6 +107,7 @@ pub const EN: &[(&str, &str)] = &[
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
("Fehler: {err}", "Error: {err}"),
("Fehlermeldung: {message}", "Error message: {message}"),
("Formeln nach „=“ ausrechnen", "Compute formulas after “=”"),
("Formen (Shape)", "Shapes"),
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
("Fünfeck (Pentagon)", "Pentagon"),
@@ -186,6 +187,7 @@ pub const EN: &[(&str, &str)] = &[
("Mindestdruck", "Minimum pressure"),
("Mit dem Finger zeichnen", "Draw with finger"),
("Mittel", "Medium"),
("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b.", "After a handwritten “=” the result appears next to it, e.g. 2⁴ = 16. Tapping it with the pen or clicking inserts it as text.\nDigits, + − · : /, parentheses, exponents, fractions and x, y, a, b are read."),
("Name …", "Name …"),
("Neon-Gelb", "Neon yellow"),
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
+3
View File
@@ -18,6 +18,9 @@ pub mod i18n;
pub mod image_layer;
pub mod import;
pub mod library;
pub mod math;
pub mod math_assist;
pub mod math_ink;
pub mod osk;
pub mod page_export;
pub mod paths;
+757
View File
@@ -0,0 +1,757 @@
//! Exact evaluation of handwritten math (Issue #46, stage 1).
//!
//! Takes the text of a recognised expression such as `2^4`, `2,5 · 3` or `(x + 3)²`
//! (optionally ending with `=`) and returns its result as text: `16`, `7,5` and
//! `x² + 6x + 9`. Numbers are exact fractions, so `1 : 3 · 3` gives `1`, and expressions
//! with variables are expanded into polynomials.
//!
//! The input is lenient towards how math is written by hand: decimal comma or point,
//! `·`, `×`, `*` and `:`, `÷`, `/`, superscript exponents, Unicode minus signs and implicit
//! multiplication (`2x`, `3(x + 1)`, `(a + b)(a − b)`).
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt::Write as _;
/// Largest exponent for a polynomial that is not a plain number
const MAX_POLYNOMIAL_EXPONENT: u32 = 32;
/// Largest exponent for a plain number
const MAX_NUMBER_EXPONENT: u32 = 1000;
/// Results with more terms are not shown (they would not fit next to the `=` anyway)
const MAX_TERMS: usize = 64;
/// Terminating decimals with more digits after the separator are shown as fractions
const MAX_DECIMALS: u32 = 12;
/// Computes the result of `expression`, written for display after its `=` sign.
///
/// `decimal_comma` selects the decimal separator of the result. Returns `None` if the
/// expression cannot be read or computed (e.g. division by zero, a variable in a
/// denominator or overflow), and also if there is nothing to compute, i.e. the result would
/// just repeat the input (`5`, `x`, `2x`).
pub fn solve(expression: &str, decimal_comma: bool) -> Option<String> {
let tokens = tokenize(expression)?;
let ast = Parser::new(&tokens).parse()?;
if matches!(ast, Expr::Number(_) | Expr::Variable(_)) {
return None;
}
let result = ast.eval()?;
if result.terms.len() > MAX_TERMS {
return None;
}
let text = result.format(decimal_comma);
let compact = |s: &str| -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.map(|c| if c == '−' { '-' } else { c })
.collect()
};
if compact(&text) == compact(expression.trim().trim_end_matches('=')) {
return None;
}
Some(text)
}
/// `expression` written neatly for showing it, e.g. `2⁴` for `2^(4)`, `(x + 1)/2` for
/// `((x+1)/(2))` or `2,5 · 3` for `2,5*3`. `None` if it cannot be read.
pub fn display(expression: &str, decimal_comma: bool) -> Option<String> {
let tokens = tokenize(expression)?;
let ast = Parser::new(&tokens).parse()?;
Some(ast.display(decimal_comma))
}
// --- Rational numbers ---------------------------------------------------------------------
/// Exact fraction `num / den` with `den > 0`, always reduced
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rational {
num: i128,
den: i128,
}
fn gcd(mut a: i128, mut b: i128) -> i128 {
a = a.abs();
b = b.abs();
while b != 0 {
(a, b) = (b, a % b);
}
a
}
impl Rational {
const ZERO: Rational = Rational { num: 0, den: 1 };
const ONE: Rational = Rational { num: 1, den: 1 };
fn new(num: i128, den: i128) -> Option<Rational> {
// i128::MIN has no positive counterpart, which `gcd` and `neg` need
if den == 0 || num == i128::MIN || den == i128::MIN {
return None;
}
let g = gcd(num, den).max(1);
let (mut num, mut den) = (num / g, den / g);
if den < 0 {
num = num.checked_neg()?;
den = den.checked_neg()?;
}
Some(Rational { num, den })
}
fn integer(n: i128) -> Rational {
Rational { num: n, den: 1 }
}
fn is_zero(self) -> bool {
self.num == 0
}
fn add(self, other: Rational) -> Option<Rational> {
let g = gcd(self.den, other.den);
let den = (self.den / g).checked_mul(other.den)?;
let num = self
.num
.checked_mul(other.den / g)?
.checked_add(other.num.checked_mul(self.den / g)?)?;
Rational::new(num, den)
}
fn neg(self) -> Option<Rational> {
Some(Rational {
num: self.num.checked_neg()?,
den: self.den,
})
}
fn mul(self, other: Rational) -> Option<Rational> {
// Cross-reducing first keeps the intermediate products small
let g1 = gcd(self.num, other.den).max(1);
let g2 = gcd(other.num, self.den).max(1);
let num = (self.num / g1).checked_mul(other.num / g2)?;
let den = (self.den / g2).checked_mul(other.den / g1)?;
Rational::new(num, den)
}
fn recip(self) -> Option<Rational> {
Rational::new(self.den, self.num)
}
fn pow(self, exponent: i128) -> Option<Rational> {
if exponent.unsigned_abs() > MAX_NUMBER_EXPONENT as u128 {
return None;
}
let base = if exponent < 0 { self.recip()? } else { self };
let mut result = Rational::ONE;
for _ in 0..exponent.unsigned_abs() {
result = result.mul(base)?;
}
Some(result)
}
fn as_integer(self) -> Option<i128> {
(self.den == 1).then_some(self.num)
}
/// The number without its sign: as a terminating decimal if it has one with at most
/// [`MAX_DECIMALS`] digits, otherwise as a fraction `p/q`.
fn format_abs(self, decimal_comma: bool) -> String {
let num = self.num.unsigned_abs();
let den = self.den.unsigned_abs();
let whole = num / den;
let mut rest = num % den;
if rest == 0 {
return whole.to_string();
}
let mut reduced = den;
let mut decimals = 0;
for factor in [2, 5] {
while reduced.is_multiple_of(factor) {
reduced /= factor;
}
}
if reduced == 1 {
let mut digits = String::new();
while rest != 0 && decimals <= MAX_DECIMALS {
let Some(shifted) = rest.checked_mul(10) else {
break;
};
rest = shifted;
digits.push(char::from(b'0' + (rest / den) as u8));
rest %= den;
decimals += 1;
}
if rest == 0 {
let separator = if decimal_comma { ',' } else { '.' };
return format!("{whole}{separator}{digits}");
}
}
format!("{num}/{den}")
}
}
// --- Polynomials --------------------------------------------------------------------------
/// Product of variables with their exponents, e.g. `a²b` = `{a: 2, b: 1}`
type Monomial = BTreeMap<char, u32>;
/// Sum of monomials with exact coefficients; a plain number is the empty monomial
#[derive(Debug, Clone, PartialEq, Eq)]
struct Polynomial {
terms: BTreeMap<Monomial, Rational>,
}
impl Polynomial {
fn constant(value: Rational) -> Polynomial {
let mut terms = BTreeMap::new();
if !value.is_zero() {
terms.insert(Monomial::new(), value);
}
Polynomial { terms }
}
fn variable(name: char) -> Polynomial {
let mut terms = BTreeMap::new();
terms.insert(Monomial::from([(name, 1)]), Rational::ONE);
Polynomial { terms }
}
/// The value if this is a plain number
fn as_constant(&self) -> Option<Rational> {
match self.terms.len() {
0 => Some(Rational::ZERO),
1 => self.terms.get(&Monomial::new()).copied(),
_ => None,
}
}
fn add_term(&mut self, monomial: Monomial, coefficient: Rational) -> Option<()> {
let sum = match self.terms.get(&monomial) {
Some(existing) => existing.add(coefficient)?,
None => coefficient,
};
if sum.is_zero() {
self.terms.remove(&monomial);
} else {
self.terms.insert(monomial, sum);
}
Some(())
}
fn add(mut self, other: &Polynomial) -> Option<Polynomial> {
for (monomial, coefficient) in &other.terms {
self.add_term(monomial.clone(), *coefficient)?;
}
Some(self)
}
fn neg(&self) -> Option<Polynomial> {
let mut terms = BTreeMap::new();
for (monomial, coefficient) in &self.terms {
terms.insert(monomial.clone(), coefficient.neg()?);
}
Some(Polynomial { terms })
}
fn mul(&self, other: &Polynomial) -> Option<Polynomial> {
let mut product = Polynomial::constant(Rational::ZERO);
for (m1, c1) in &self.terms {
for (m2, c2) in &other.terms {
let mut monomial = m1.clone();
for (&name, &exponent) in m2 {
let e = monomial.entry(name).or_insert(0);
*e = e.checked_add(exponent)?;
}
product.add_term(monomial, c1.mul(*c2)?)?;
}
if product.terms.len() > MAX_TERMS * 4 {
return None;
}
}
Some(product)
}
fn div(&self, divisor: &Polynomial) -> Option<Polynomial> {
let factor = divisor.as_constant()?.recip()?;
self.mul(&Polynomial::constant(factor))
}
fn pow(&self, exponent: &Polynomial) -> Option<Polynomial> {
let exponent = exponent.as_constant()?.as_integer()?;
if let Some(base) = self.as_constant() {
return Some(Polynomial::constant(base.pow(exponent)?));
}
if !(0..=MAX_POLYNOMIAL_EXPONENT as i128).contains(&exponent) {
return None;
}
let mut result = Polynomial::constant(Rational::ONE);
for _ in 0..exponent {
result = result.mul(self)?;
}
Some(result)
}
/// E.g. `x² + 6x + 9`, `a² − b²`, `7,5`: highest degree first, then in alphabetical
/// order of the variables (`a² + 2ab + b²`).
fn format(&self, decimal_comma: bool) -> String {
if self.terms.is_empty() {
return "0".to_owned();
}
let degree = |m: &Monomial| m.values().sum::<u32>();
let mut terms: Vec<_> = self.terms.iter().collect();
terms.sort_by(|(a, _), (b, _)| degree(b).cmp(&degree(a)).then_with(|| graded_order(a, b)));
let mut out = String::new();
for (index, (monomial, coefficient)) in terms.into_iter().enumerate() {
let negative = coefficient.num < 0;
match (index, negative) {
(0, true) => out.push('−'),
(0, false) => {}
(_, true) => out.push_str(" − "),
(_, false) => out.push_str(" + "),
}
let magnitude = coefficient.format_abs(decimal_comma);
if monomial.is_empty() {
out.push_str(&magnitude);
continue;
}
if magnitude.contains('/') {
let _ = write!(out, "({magnitude})");
} else if magnitude != "1" {
out.push_str(&magnitude);
}
for (&name, &exponent) in monomial {
out.push(name);
if exponent > 1 {
out.push_str(&superscript(exponent));
}
}
}
out
}
}
/// Order within the same degree: the monomial with the higher power of the alphabetically
/// first variable comes first (`a²`, `ab`, `b²`).
fn graded_order(a: &Monomial, b: &Monomial) -> Ordering {
let names: std::collections::BTreeSet<char> = a.keys().chain(b.keys()).copied().collect();
for name in names {
let ea = a.get(&name).copied().unwrap_or(0);
let eb = b.get(&name).copied().unwrap_or(0);
match eb.cmp(&ea) {
Ordering::Equal => continue,
other => return other,
}
}
Ordering::Equal
}
const SUPERSCRIPT_DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
fn superscript(n: u32) -> String {
n.to_string()
.chars()
.map(|d| SUPERSCRIPT_DIGITS[d.to_digit(10).unwrap_or(0) as usize])
.collect()
}
// --- Reading the expression ---------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(Rational),
Variable(char),
Plus,
Minus,
Times,
/// `/` for a fraction, `:` for a division sign (`:` and `÷`)
Divide(char),
Power,
Open,
Close,
}
fn tokenize(input: &str) -> Option<Vec<Token>> {
let input = input.trim().trim_end_matches('=').trim_end();
let chars: Vec<char> = input.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_ascii_digit() {
let (value, next) = read_number(&chars, i)?;
tokens.push(Token::Number(value));
i = next;
continue;
}
if superscript_value(c).is_some() || c == '⁻' {
// A run of superscript characters is the exponent: x²³ = x^23, 2⁻¹ = 2^(-1)
let negative = c == '⁻';
let start = if negative { i + 1 } else { i };
let mut end = start;
let mut value: i128 = 0;
while let Some(digit) = chars.get(end).and_then(|&d| superscript_value(d)) {
value = value.checked_mul(10)?.checked_add(digit)?;
end += 1;
}
if end == start {
return None;
}
tokens.push(Token::Power);
if negative {
tokens.push(Token::Minus);
}
tokens.push(Token::Number(Rational::integer(value)));
i = end;
continue;
}
let token = match c {
_ if c.is_whitespace() => None,
'+' => Some(Token::Plus),
'-' | '−' | '–' => Some(Token::Minus),
'*' | '·' | '⋅' | '∙' | '×' => Some(Token::Times),
'/' => Some(Token::Divide('/')),
':' | '÷' => Some(Token::Divide(':')),
'^' => Some(Token::Power),
'(' | '[' | '{' => Some(Token::Open),
')' | ']' | '}' => Some(Token::Close),
_ if c.is_ascii_alphabetic() => Some(Token::Variable(c)),
_ => return None,
};
tokens.extend(token);
i += 1;
}
Some(tokens)
}
fn superscript_value(c: char) -> Option<i128> {
SUPERSCRIPT_DIGITS
.iter()
.position(|&d| d == c)
.map(|p| p as i128)
}
/// Reads `12`, `2,5` or `0.125` from `start`; returns the value and the index after it
fn read_number(chars: &[char], start: usize) -> Option<(Rational, usize)> {
let mut i = start;
let mut num: i128 = 0;
let mut den: i128 = 1;
let mut in_fraction = false;
while i < chars.len() {
let c = chars[i];
if let Some(digit) = c.to_digit(10) {
num = num.checked_mul(10)?.checked_add(digit as i128)?;
if in_fraction {
den = den.checked_mul(10)?;
}
} else if (c == ',' || c == '.')
&& !in_fraction
&& chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())
{
in_fraction = true;
} else {
break;
}
i += 1;
}
Some((Rational::new(num, den)?, i))
}
#[derive(Debug, Clone, PartialEq)]
enum Expr {
Number(Rational),
Variable(char),
Neg(Box<Expr>),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
/// With the sign it was written with, for [`display`]
Div(Box<Expr>, Box<Expr>, char),
Pow(Box<Expr>, Box<Expr>),
}
impl Expr {
fn eval(&self) -> Option<Polynomial> {
Some(match self {
Expr::Number(value) => Polynomial::constant(*value),
Expr::Variable(name) => Polynomial::variable(*name),
Expr::Neg(e) => e.eval()?.neg()?,
Expr::Add(a, b) => a.eval()?.add(&b.eval()?)?,
Expr::Sub(a, b) => a.eval()?.add(&b.eval()?.neg()?)?,
Expr::Mul(a, b) => a.eval()?.mul(&b.eval()?)?,
Expr::Div(a, b, _) => a.eval()?.div(&b.eval()?)?,
Expr::Pow(a, b) => a.eval()?.pow(&b.eval()?)?,
})
}
}
impl Expr {
/// Binding strength for deciding on parentheses: sums < products < signs < powers < atoms
fn precedence(&self) -> u8 {
match self {
Expr::Add(..) | Expr::Sub(..) => 1,
Expr::Mul(..) | Expr::Div(..) => 2,
Expr::Neg(_) => 3,
Expr::Pow(..) => 4,
Expr::Number(_) | Expr::Variable(_) => 5,
}
}
/// The text of this expression, in parentheses if it binds weaker than `min`
fn display_at(&self, min: u8, decimal_comma: bool) -> String {
let text = self.display(decimal_comma);
if self.precedence() < min {
format!("({text})")
} else {
text
}
}
fn display(&self, decimal_comma: bool) -> String {
let dc = decimal_comma;
match self {
Expr::Number(value) => value.format_abs(dc),
Expr::Variable(name) => name.to_string(),
Expr::Neg(e) => format!("−{}", e.display_at(3, dc)),
Expr::Add(a, b) => format!("{} + {}", a.display_at(1, dc), b.display_at(1, dc)),
Expr::Sub(a, b) => format!("{} − {}", a.display_at(1, dc), b.display_at(2, dc)),
Expr::Mul(a, b) => {
let left = a.display_at(2, dc);
let right = b.display_at(3, dc);
// 2x, 3(x + 1), x², but 2 · 3 and x · 2
let implicit = !matches!(**a, Expr::Neg(_))
&& (matches!(**b, Expr::Variable(_))
|| matches!(&**b, Expr::Pow(base, _) if matches!(**base, Expr::Variable(_)))
|| right.starts_with('('));
if implicit {
format!("{left}{right}")
} else {
format!("{left} · {right}")
}
}
Expr::Div(a, b, sign) => match sign {
'/' => format!("{}/{}", a.display_at(4, dc), b.display_at(4, dc)),
_ => format!("{} : {}", a.display_at(2, dc), b.display_at(3, dc)),
},
Expr::Pow(base, exponent) => {
let base = base.display_at(5, dc);
let integer = |e: &Expr| match e {
Expr::Number(v) => v.as_integer().map(|n| superscript(n as u32)),
_ => None,
};
let small = match &**exponent {
Expr::Neg(e) => integer(e).map(|s| format!("⁻{s}")),
e => integer(e),
};
match small {
Some(small) => format!("{base}{small}"),
None => format!("{base}^({})", exponent.display(dc)),
}
}
}
}
}
/// Recursive descent with the usual precedence: `+ −` < `· :` and implicit
/// multiplication < unary minus < `^` (right-associative, so `−2² = −4` and `2^3^2 = 2^9`).
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(tokens: &'a [Token]) -> Self {
Parser { tokens, pos: 0 }
}
fn parse(mut self) -> Option<Expr> {
let expr = self.sum()?;
(self.pos == self.tokens.len()).then_some(expr)
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn eat(&mut self, token: &Token) -> bool {
let found = self.peek() == Some(token);
if found {
self.pos += 1;
}
found
}
fn sum(&mut self) -> Option<Expr> {
let mut left = self.product()?;
loop {
if self.eat(&Token::Plus) {
left = Expr::Add(Box::new(left), Box::new(self.product()?));
} else if self.eat(&Token::Minus) {
left = Expr::Sub(Box::new(left), Box::new(self.product()?));
} else {
return Some(left);
}
}
}
fn product(&mut self) -> Option<Expr> {
let mut left = self.unary()?;
loop {
if self.eat(&Token::Times) {
left = Expr::Mul(Box::new(left), Box::new(self.unary()?));
} else if let Some(&Token::Divide(sign)) = self.peek() {
self.pos += 1;
left = Expr::Div(Box::new(left), Box::new(self.unary()?), sign);
} else if matches!(self.peek(), Some(Token::Variable(_) | Token::Open)) {
// Implicit multiplication: 2x, 3(x + 1), (a + b)(a − b). Two numbers in a
// row are not multiplied, they are more likely a misread.
left = Expr::Mul(Box::new(left), Box::new(self.power()?));
} else {
return Some(left);
}
}
}
fn unary(&mut self) -> Option<Expr> {
if self.eat(&Token::Minus) {
return Some(Expr::Neg(Box::new(self.unary()?)));
}
if self.eat(&Token::Plus) {
return self.unary();
}
self.power()
}
fn power(&mut self) -> Option<Expr> {
let base = self.atom()?;
if self.eat(&Token::Power) {
return Some(Expr::Pow(Box::new(base), Box::new(self.unary()?)));
}
Some(base)
}
fn atom(&mut self) -> Option<Expr> {
let token = self.peek()?.clone();
self.pos += 1;
match token {
Token::Number(value) => Some(Expr::Number(value)),
Token::Variable(name) => Some(Expr::Variable(name)),
Token::Open => {
let inner = self.sum()?;
self.eat(&Token::Close).then_some(inner)
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn de(expression: &str) -> Option<String> {
solve(expression, true)
}
#[test]
fn arithmetic() {
assert_eq!(de("2^4 ="), Some("16".into()));
assert_eq!(de("2⁴="), Some("16".into()));
assert_eq!(de("1 + 2 · 3"), Some("7".into()));
assert_eq!(de("(1 + 2) × 3"), Some("9".into()));
assert_eq!(de("7 − 10"), Some("−3".into()));
assert_eq!(de("−2^2"), Some("−4".into()));
assert_eq!(de("(−2)²"), Some("4".into()));
assert_eq!(de("2^3^2"), Some("512".into()));
assert_eq!(de("10 : 4"), Some("2,5".into()));
assert_eq!(de("10 ÷ 4"), Some("2,5".into()));
assert_eq!(de("3 * [2 + 1]"), Some("9".into()));
}
#[test]
fn decimals_and_fractions() {
assert_eq!(de("2,5 · 3"), Some("7,5".into()));
assert_eq!(solve("2.5 * 3", false), Some("7.5".into()));
assert_eq!(de("0,1 + 0,2"), Some("0,3".into()));
assert_eq!(de("1 : 3"), Some("1/3".into()));
assert_eq!(de("1 : 3 · 3"), Some("1".into()));
assert_eq!(de("2^-2"), Some("0,25".into()));
assert_eq!(de("2⁻¹"), Some("0,5".into()));
assert_eq!(de("1 : 8"), Some("0,125".into()));
}
#[test]
fn polynomials() {
assert_eq!(de("(x + 3)^2 ="), Some("x² + 6x + 9".into()));
assert_eq!(de("(x+3)²"), Some("x² + 6x + 9".into()));
assert_eq!(de("(a + b)²"), Some("a² + 2ab + b²".into()));
assert_eq!(de("(a + b)(a − b)"), Some("a² − b²".into()));
assert_eq!(de("(a − b)²"), Some("a² − 2ab + b²".into()));
assert_eq!(de("2x + 3x"), Some("5x".into()));
assert_eq!(de("x · x"), Some("x²".into()));
assert_eq!(de("3(x + 1)"), Some("3x + 3".into()));
assert_eq!(de("(x + 1)^3"), Some("x³ + 3x² + 3x + 1".into()));
assert_eq!(de("(2x − 1)²"), Some("4x² − 4x + 1".into()));
assert_eq!(de("x − x"), Some("0".into()));
assert_eq!(de("(x + 1) : 2"), Some("0,5x + 0,5".into()));
assert_eq!(de("x : 3"), Some("(1/3)x".into()));
assert_eq!(de("−(x + 1)"), Some("−x − 1".into()));
assert_eq!(
de("(x+1)^10").map(|s| s.starts_with("x¹⁰ + 10x⁹")),
Some(true)
);
}
#[test]
fn nothing_to_compute() {
assert_eq!(de("5"), None);
assert_eq!(de("x ="), None);
assert_eq!(de("2x"), None);
assert_eq!(de("x² + 1"), None);
assert_eq!(de("2,5"), None);
}
#[test]
fn unreadable_or_not_computable() {
assert_eq!(de(""), None);
assert_eq!(de("="), None);
assert_eq!(de("1 +"), None);
assert_eq!(de("(1 + 2"), None);
assert_eq!(de("1 + 2)"), None);
assert_eq!(de("2 3"), None);
assert_eq!(de("1 : 0"), None);
assert_eq!(de("0^-1"), None);
assert_eq!(de("1 : x"), None);
assert_eq!(de("x^y"), None);
assert_eq!(de("x^0,5"), None);
assert_eq!(de("2 ? 3"), None);
assert_eq!(de("1,2,3"), None);
}
#[test]
fn displays_expressions() {
let show = |e: &str| display(e, true).unwrap();
assert_eq!(show("2^(4)"), "2⁴");
assert_eq!(show("(x+3)^(2)"), "(x + 3)²");
assert_eq!(show("2,5·3"), "2,5 · 3");
assert_eq!(show("9:3"), "9 : 3");
assert_eq!(show("((x+1)/(2))"), "(x + 1)/2");
assert_eq!(show("((1)/(2))+((1)/(2·3))"), "1/2 + 1/(2 · 3)");
assert_eq!(show("2^(-1)"), "2⁻¹");
assert_eq!(show("2^(x+1)"), "2^(x + 1)");
assert_eq!(show("3(x+1)"), "3(x + 1)");
assert_eq!(show("2x^(2)"), "2x²");
assert_eq!(show("x·2"), "x · 2");
assert_eq!(show("(a+b)(a-b)"), "(a + b)(a − b)");
assert_eq!(show("7-(5-2)"), "7 − (5 − 2)");
assert_eq!(show("-2^(2)"), "−2²");
assert_eq!(show("(-2)^(2)"), "(−2)²");
assert_eq!(show("0.5"), "0,5");
assert!(display("1 +", true).is_none());
}
#[test]
fn overflow_and_limits() {
assert_eq!(de("10^100"), None);
assert_eq!(de("2^100000"), None);
assert_eq!(de("(x + 1)^33"), None);
assert_eq!(de("(a+b+c+d+e)^8"), None);
assert_eq!(de("99999999999999999999999999999999999999999999 + 1"), None);
assert_eq!(de("2^64"), Some("18446744073709551616".into()));
}
}
+465
View File
@@ -0,0 +1,465 @@
//! Computes handwritten math after an `=` (Issue #46, stage 3).
//!
//! When the last two strokes on a page form an `=` and the pen then rests for
//! [`TRIGGER_DELAY`], the strokes left of it are recognized ([`crate::math_ink`]) and computed
//! ([`crate::math::solve`]). The result is offered next to the `=` as a suggestion that also
//! shows what was read (`2⁴ = 16`), so that a misread symbol is noticed. Tapping it with the
//! pen or clicking it inserts the result as a text box; fingers and palms are ignored. Any new
//! stroke drops the suggestion. Off by default (☰ → Input).
use std::sync::Arc;
use std::time::{Duration, Instant};
use glam::Vec2;
use crate::document::{Page, Stroke, StrokeTool};
use crate::math_ink::Recognizer;
/// Pause after the `=` before its expression is computed; a new stroke cancels it
pub const TRIGGER_DELAY: Duration = Duration::from_millis(500);
/// Estimated symbol height relative to the width of the `=`
const SYMBOL_PER_EQUALS: f32 = 1.3;
/// Strokes left of the `=` belong to the expression up to a gap of this many symbol heights
const MAX_GAP: f32 = 2.0;
/// At most this many strokes are read as the expression
const MAX_STROKES: usize = 80;
/// Cap height of the text font relative to its size (Ubuntu: 0.69 em)
const CAP_HEIGHT_PER_EM: f32 = 0.7;
/// A result offered next to an `=`
#[derive(Debug, Clone, PartialEq)]
pub struct Suggestion {
pub page_id: u64,
/// The two strokes of the `=`
pub equals: [u64; 2],
/// What was read, written neatly (`2⁴`)
pub expression: String,
/// The result (`16`)
pub result: String,
/// Where the result goes: left end of the text on the vertical middle of the `=`
pub anchor: Vec2,
/// Height of the handwritten digits in page units
pub symbol_height: f32,
/// Ink color of the `=`
pub color: [f32; 4],
}
impl Suggestion {
/// Top left corner and size (em) of a text box that puts the result next to the `=`, as
/// high as the handwriting
pub fn text_placement(&self, ascent_per_em: f32) -> (Vec2, f32) {
let size = self.symbol_height / CAP_HEIGHT_PER_EM;
// Baseline at the bottom of digits centered on the `=`
let baseline = self.anchor.y + self.symbol_height / 2.0;
(
Vec2::new(self.anchor.x, baseline - ascent_per_em * size),
size,
)
}
}
#[derive(Debug, Clone)]
struct Pending {
page_id: u64,
equals: [u64; 2],
due: Instant,
}
/// State of the math assistant: waiting after an `=`, and the suggestion shown
#[derive(Default, Clone)]
pub struct MathAssist {
pub enabled: bool,
/// Built on first use
recognizer: Option<Arc<Recognizer>>,
pending: Option<Pending>,
suggestion: Option<Suggestion>,
/// Where the UI drew the suggestion (egui points), for [`Self::take_hit`]
pub chip_rect: Option<egui::Rect>,
}
impl std::fmt::Debug for MathAssist {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MathAssist")
.field("enabled", &self.enabled)
.field("pending", &self.pending)
.field("suggestion", &self.suggestion)
.finish_non_exhaustive()
}
}
impl MathAssist {
/// After a stroke was added to `page`: drops the suggestion and waits if the stroke
/// completed an `=`
pub fn stroke_committed(&mut self, page: &Page, now: Instant) {
self.suggestion = None;
self.chip_rect = None;
self.pending = None;
if !self.enabled {
return;
}
if let Some((a, b)) = equals_at_end(&page.strokes) {
self.pending = Some(Pending {
page_id: page.id,
equals: [page.strokes[a].id, page.strokes[b].id],
due: now + TRIGGER_DELAY,
});
}
}
/// When [`Self::update`] has something to do
pub fn deadline(&self) -> Option<Instant> {
self.pending.as_ref().map(|p| p.due)
}
/// Computes the expression once the pause after the `=` is over (not while a stroke is
/// being drawn). True if a suggestion appeared.
pub fn update(
&mut self,
page: &Page,
now: Instant,
drawing: bool,
decimal_comma: bool,
) -> bool {
let Some(pending) = self.pending.as_ref() else {
return false;
};
if drawing || now < pending.due {
return false;
}
let pending = self.pending.take().expect("checked above");
if pending.page_id != page.id || !self.enabled {
return false;
}
let recognizer = self
.recognizer
.get_or_insert_with(|| Arc::new(Recognizer::new()));
self.suggestion = suggest(recognizer, page, pending.equals, decimal_comma);
self.suggestion.is_some()
}
/// The suggestion if it belongs to `page` and its `=` is still there
pub fn suggestion_for(&self, page: &Page) -> Option<&Suggestion> {
self.suggestion.as_ref().filter(|s| {
s.page_id == page.id
&& s.equals
.iter()
.all(|id| page.strokes.iter().any(|stroke| stroke.id == *id))
})
}
/// The suggestion if `pos` (egui points) is on it; it is then taken away
pub fn take_hit(&mut self, pos: egui::Pos2) -> Option<Suggestion> {
if !self.chip_rect.is_some_and(|r| r.contains(pos)) {
return None;
}
self.chip_rect = None;
self.suggestion.take()
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
if !enabled {
self.pending = None;
self.suggestion = None;
self.chip_rect = None;
}
}
}
/// Handwriting that can be read: pen strokes (also snapped lines), no highlighter, text or
/// images
fn is_ink(stroke: &Stroke) -> bool {
!stroke.is_object() && stroke.tool != StrokeTool::Highlighter && !stroke.points.is_empty()
}
fn positions(stroke: &Stroke) -> Vec<Vec2> {
stroke.points.iter().map(|s| s.position).collect()
}
/// A straight, roughly horizontal stroke: one bar of an `=`
fn is_bar(stroke: &Stroke) -> bool {
let size = stroke.aabb.max - stroke.aabb.min;
let length: f32 = positions(stroke)
.windows(2)
.map(|w| w[0].distance(w[1]))
.sum();
size.x > 0.0 && size.x >= 2.5 * size.y && length <= 1.3 * size.x
}
/// Indices of the last two ink strokes if they form an `=`: two bars of similar length, one
/// above the other
fn equals_at_end(strokes: &[Stroke]) -> Option<(usize, usize)> {
let mut ink = strokes.iter().enumerate().rev().filter(|(_, s)| is_ink(s));
let (b, second) = ink.next()?;
let (a, first) = ink.next()?;
if !is_bar(first) || !is_bar(second) {
return None;
}
let (w1, w2) = (
first.aabb.max.x - first.aabb.min.x,
second.aabb.max.x - second.aabb.min.x,
);
let (short, long) = (w1.min(w2), w1.max(w2));
let overlap = first.aabb.max.x.min(second.aabb.max.x) - first.aabb.min.x.max(second.aabb.min.x);
let dy = ((first.aabb.min.y + first.aabb.max.y) - (second.aabb.min.y + second.aabb.max.y))
.abs()
/ 2.0;
(overlap >= 0.5 * short && long <= 2.0 * short && dy >= 0.1 * long && dy <= long)
.then_some((a, b))
}
/// Indices of the strokes left of the `=` that make up its expression, in the order they were
/// written: ink in a band around the `=`, going left until a wide gap
fn expression_strokes(strokes: &[Stroke], equals: (usize, usize)) -> Vec<usize> {
let (e1, e2) = (&strokes[equals.0], &strokes[equals.1]);
let min = e1.aabb.min.min(e2.aabb.min);
let max = e1.aabb.max.max(e2.aabb.max);
let height = (max.x - min.x) * SYMBOL_PER_EQUALS;
let middle = (min.y + max.y) / 2.0;
let mut candidates: Vec<usize> = (0..strokes.len())
.filter(|&i| i != equals.0 && i != equals.1 && is_ink(&strokes[i]))
.filter(|&i| {
let s = &strokes[i].aabb;
let center = (s.min.y + s.max.y) / 2.0;
(center - middle).abs() <= 2.0 * height
&& s.max.y - s.min.y <= 3.0 * height
&& s.max.x <= min.x + 0.2 * height
})
.collect();
candidates.sort_by(|&a, &b| strokes[b].aabb.max.x.total_cmp(&strokes[a].aabb.max.x));
let mut left = min.x;
let mut chosen = Vec::new();
for i in candidates {
let s = &strokes[i].aabb;
if s.max.x < left - MAX_GAP * height || chosen.len() >= MAX_STROKES {
break;
}
left = left.min(s.min.x);
chosen.push(i);
}
chosen.sort_unstable();
chosen
}
/// Reads and computes the expression in front of the `=` made of the strokes `equals`
fn suggest(
recognizer: &Recognizer,
page: &Page,
equals: [u64; 2],
decimal_comma: bool,
) -> Option<Suggestion> {
let strokes = &page.strokes;
let index = |id: u64| strokes.iter().position(|s| s.id == id);
let pair = (index(equals[0])?, index(equals[1])?);
let mut chosen = expression_strokes(strokes, pair);
if chosen.is_empty() {
return None;
}
chosen.extend([pair.0, pair.1]);
let points: Vec<Vec<Vec2>> = chosen.iter().map(|&i| positions(&strokes[i])).collect();
let recognition = recognizer.recognize(&points)?;
// The `=` must be read as the last symbol, from exactly its two strokes
let last = recognition.symbols.last()?;
let equals_strokes = [points.len() - 2, points.len() - 1];
let mut last_strokes = last.strokes.clone();
last_strokes.sort_unstable();
if last.ch != '=' || last_strokes != equals_strokes {
return None;
}
// In a chain like `x = 2 + 3 =` only the part after the previous `=` is computed
let text = recognition.text.strip_suffix('=')?;
let expression = text.rsplit('=').next()?;
// A dot or comma that was written decides the separator of the result
let decimal_comma = if expression.contains(',') {
true
} else if expression.contains('.') {
false
} else {
decimal_comma
};
let result = crate::math::solve(expression, decimal_comma)?;
let shown = crate::math::display(expression, decimal_comma)?;
let (e1, e2) = (&strokes[pair.0], &strokes[pair.1]);
let right = e1.aabb.max.x.max(e2.aabb.max.x);
let middle = (e1.aabb.min.y.min(e2.aabb.min.y) + e1.aabb.max.y.max(e2.aabb.max.y)) / 2.0;
let mut heights: Vec<f32> = recognition
.symbols
.iter()
.filter(|s| s.ch.is_ascii_digit() || s.ch.is_ascii_alphabetic())
.map(|s| s.max.y - s.min.y)
.collect();
heights.sort_by(f32::total_cmp);
let width = right - e1.aabb.min.x.min(e2.aabb.min.x);
let symbol_height = heights
.last()
.copied()
.unwrap_or(width * SYMBOL_PER_EQUALS)
.max(1.0);
Some(Suggestion {
page_id: page.id,
equals,
expression: shown,
result,
anchor: Vec2::new(right + 0.4 * symbol_height, middle),
symbol_height,
color: e2.color,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StylusSample;
use crate::math_ink::tests::{write_line, Jitter};
const BLACK: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
fn stroke(id: u64, points: &[Vec2]) -> Stroke {
let samples = points
.iter()
.enumerate()
.map(|(i, &position)| StylusSample {
position,
pressure: 0.5,
timestamp: i as f64 * 0.01,
})
.collect();
Stroke::new(id, samples, Vec::new(), BLACK)
}
/// A page with `text` written at `origin`, scaled by `scale`
fn page_with(lines: &[(&str, Vec2)], scale: f32) -> Page {
let mut page = Page::new(7, crate::document::PageBackground::Blank);
let mut id = 1;
for (seed, (text, origin)) in lines.iter().enumerate() {
for points in write_line(text, &mut Jitter(seed as u64 + 3)) {
let points: Vec<Vec2> = points.iter().map(|p| *origin + *p * scale).collect();
page.add_stroke(stroke(id, &points));
id += 1;
}
}
page
}
fn run(page: &Page) -> Option<Suggestion> {
let mut assist = MathAssist {
enabled: true,
..Default::default()
};
let start = Instant::now();
assist.stroke_committed(page, start);
assert!(!assist.update(page, start, false, true), "too early");
assist.update(page, start + TRIGGER_DELAY, false, true);
assist.suggestion_for(page).cloned()
}
#[test]
fn suggests_result_after_equals() {
let page = page_with(&[("(x+3)^2=", Vec2::new(100.0, 200.0))], 3.0);
let suggestion = run(&page).unwrap();
assert_eq!(suggestion.expression, "(x + 3)²");
assert_eq!(suggestion.result, "x² + 6x + 9");
let equals_right = page
.strokes
.iter()
.rev()
.take(2)
.map(|s| s.aabb.max.x)
.fold(f32::MIN, f32::max);
assert!(suggestion.anchor.x > equals_right);
// The handwriting is 14 units high, scaled by 3
assert!(
(suggestion.symbol_height - 42.0).abs() < 6.0,
"{suggestion:?}"
);
}
#[test]
fn ignores_writing_far_to_the_left_and_on_other_lines() {
let page = page_with(
&[
("7", Vec2::new(0.0, 200.0)),
("5·5", Vec2::new(0.0, 300.0)),
("2^4=", Vec2::new(150.0, 200.0)),
],
2.0,
);
let suggestion = run(&page).unwrap();
assert_eq!(suggestion.expression, "2⁴");
assert_eq!(suggestion.result, "16");
}
#[test]
fn computes_the_last_part_of_a_chain() {
let page = page_with(&[("x=2·3=", Vec2::new(0.0, 0.0))], 2.0);
assert_eq!(run(&page).unwrap().result, "6");
}
#[test]
fn nothing_without_equals_or_when_disabled() {
let page = page_with(&[("2+3", Vec2::ZERO)], 2.0);
assert!(run(&page).is_none());
let page = page_with(&[("2+3=", Vec2::ZERO)], 2.0);
let mut assist = MathAssist::default();
assist.stroke_committed(&page, Instant::now());
assert!(assist.deadline().is_none());
}
#[test]
fn new_stroke_or_removed_equals_drops_the_suggestion() {
let mut page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
let mut assist = MathAssist {
enabled: true,
..Default::default()
};
let start = Instant::now();
assist.stroke_committed(&page, start);
// A stroke still being drawn when the time is up: nothing yet
assert!(!assist.update(&page, start + TRIGGER_DELAY, true, true));
assert!(assist.update(&page, start + TRIGGER_DELAY, false, true));
assert_eq!(assist.suggestion_for(&page).unwrap().result, "2");
let last = page.strokes.last().unwrap().id;
page.remove_strokes_by_ids(&[last]);
assert!(assist.suggestion_for(&page).is_none());
assist.stroke_committed(&page, start);
assert!(assist.suggestion.is_none());
}
#[test]
fn hit_takes_the_suggestion() {
let page = page_with(&[("1+1=", Vec2::ZERO)], 2.0);
let mut assist = MathAssist {
enabled: true,
..Default::default()
};
let start = Instant::now();
assist.stroke_committed(&page, start);
assist.update(&page, start + TRIGGER_DELAY, false, true);
assist.chip_rect = Some(egui::Rect::from_min_size(
egui::pos2(10.0, 10.0),
egui::vec2(50.0, 20.0),
));
assert!(assist.take_hit(egui::pos2(5.0, 5.0)).is_none());
assert_eq!(assist.take_hit(egui::pos2(20.0, 20.0)).unwrap().result, "2");
assert!(assist.take_hit(egui::pos2(20.0, 20.0)).is_none());
}
#[test]
fn result_text_sits_on_the_baseline() {
let suggestion = Suggestion {
page_id: 1,
equals: [1, 2],
expression: "1 + 1".into(),
result: "2".into(),
anchor: Vec2::new(100.0, 50.0),
symbol_height: 14.0,
color: BLACK,
};
let (top_left, size) = suggestion.text_placement(0.9);
assert!((size - 20.0).abs() < 0.01);
// Baseline (top + ascent) at the bottom of digits centered on the `=`
assert!((top_left.y + 0.9 * size - 57.0).abs() < 0.01);
assert_eq!(top_left.x, 100.0);
}
}
+1259
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -232,6 +232,8 @@ pub struct UiSettings {
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
/// (Issue #49)
pub storage_dir: Option<PathBuf>,
/// Compute handwritten math after `=` (Issue #46)
pub math_assist: bool,
}
impl Default for UiSettings {
@@ -253,6 +255,7 @@ impl Default for UiSettings {
language: None,
eraser_radius_mm: 3.0,
storage_dir: None,
math_assist: false,
}
}
}
@@ -350,6 +353,7 @@ mod tests {
language: Some(Language::English),
eraser_radius_mm: 6.0,
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
math_assist: true,
};
settings.save_to(&path).unwrap();
assert_eq!(UiSettings::load_from(&path), settings);
+66
View File
@@ -317,6 +317,8 @@ pub enum UiAction {
UnlockCorruptedPage(u64),
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
SetSidebarSide(SidebarSide),
/// Compute handwritten math after `=` on or off (Issue #46)
SetMathAssist(bool),
/// Sprache der Oberfläche; `None` = Sprache des Systems
SetLanguage(Option<crate::i18n::Language>),
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
@@ -413,6 +415,8 @@ pub struct UiState {
pub update_modal_rect: Option<egui::Rect>,
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
pub touch_draw_enabled: bool,
/// Computes handwritten math after an `=` (Issue #46)
pub math: crate::math_assist::MathAssist,
/// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel)
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
@@ -546,6 +550,7 @@ impl Default for UiState {
show_updater_config: false,
update_modal_rect: None,
touch_draw_enabled: false,
math: crate::math_assist::MathAssist::default(),
last_deleted_notebook: None,
pen_presets: DEFAULT_PEN_PRESETS,
active_pen: 0,
@@ -1652,6 +1657,57 @@ fn text_style_buttons(ui: &mut egui::Ui, btn: f32, state: &mut UiState) {
/// Bearbeitung eines Textfelds: egui-Textfeld an der Stelle und in der Größe des Felds auf
/// dem Blatt (ungedreht), mit derselben Schrift. Esc beendet die Bearbeitung.
/// The result offered next to a handwritten `=` (Issue #46): what was read and the result,
/// e.g. `2⁴ = 16`. Not interactive for egui, so that fingers and palms pass through to the
/// page; the pen and the mouse take it via [`crate::math_assist::MathAssist::take_hit`].
fn math_suggestion(ctx: &egui::Context, state: &mut UiState, notebook: &Notebook) {
state.math.chip_rect = None;
if state.view_mode {
return;
}
let (Some(view), Some(suggestion)) = (
state.page_view,
state.math.suggestion_for(notebook.current_page()).cloned(),
) else {
return;
};
let size = (suggestion.symbol_height * view.scale * 0.8).clamp(14.0, 48.0);
let response = egui::Area::new(egui::Id::new("math_suggestion"))
.fixed_pos(view.to_screen(suggestion.anchor))
.pivot(egui::Align2::LEFT_CENTER)
.order(egui::Order::Foreground)
.interactable(false)
.show(ctx, |ui| {
egui::Frame::none()
.fill(egui::Color32::from_rgba_unmultiplied(30, 34, 44, 235))
.stroke(egui::Stroke::new(
1.5_f32,
egui::Color32::from_rgb(70, 130, 230),
))
.rounding(size * 0.4)
.inner_margin(egui::vec2(size * 0.45, size * 0.2))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = size * 0.25;
ui.label(
egui::RichText::new(format!("{} =", suggestion.expression))
.size(size * 0.7)
.color(egui::Color32::from_gray(170)),
);
ui.label(
egui::RichText::new(&suggestion.result)
.size(size)
.strong()
.color(egui::Color32::WHITE),
);
});
});
})
.response;
// A little larger than drawn, as the pen does not hit exactly
state.math.chip_rect = Some(response.rect.expand(6.0));
}
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
@@ -3150,6 +3206,7 @@ pub fn render_ui(
space_preview(ctx, state, notebook);
ruler_overlay(ctx, state);
text_edit_overlay(ctx, state);
math_suggestion(ctx, state, notebook);
context_menu(ctx, state, &mut actions);
let mut pen_btn_rect = None;
@@ -4383,6 +4440,15 @@ pub fn render_ui(
.on_hover_text(
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
);
let mut math = state.math.enabled;
if ui
.checkbox(&mut math, t!("Formeln nach „=“ ausrechnen"))
.on_hover_text(t!("Nach einem handgeschriebenen „=“ erscheint das Ergebnis daneben, z. B. 2⁴ = 16. Antippen mit dem Stift oder Klicken fügt es als Text ein.\nGelesen werden Ziffern, + − · : /, Klammern, Hochzahlen, Brüche und x, y, a, b."))
.changed()
{
state.math.set_enabled(math);
actions.push(UiAction::SetMathAssist(math));
}
if let Some(curve) = pressure_curve_editor(ui, state) {
actions.push(UiAction::SavePressureCurve(curve));
}