Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9a67d399 | ||
|
|
307c803246 | ||
|
|
d16cd2db72 | ||
|
|
cd490dd9a7 | ||
|
|
8af10fe542 | ||
|
|
6694d79f16 | ||
|
|
ad1dab9161 | ||
|
|
8c436fb28c | ||
|
|
7584827c04 | ||
|
|
0be321837c | ||
|
|
a8aef30db7 | ||
|
|
4ca21483e5 | ||
|
|
c95cde8ec8 | ||
|
|
6b80cd672a | ||
|
|
7faf78ddeb | ||
|
|
ce3a1f697b | ||
|
|
e47c2b6f28 |
No files matched your search
@@ -13,10 +13,18 @@ jobs:
|
|||||||
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
|
# 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.
|
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# The APK from the android job goes into the release and its SHA256SUMS.txt
|
||||||
|
needs: android
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4.2.2
|
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
|
- name: Install System Packaging Tools
|
||||||
run: |
|
run: |
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -63,13 +71,14 @@ jobs:
|
|||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
- name: Build Release Artifacts (.deb, .AppImage, .exe, SHA256SUMS.txt + .minisig)
|
- name: Build Release Artifacts (.deb, .AppImage, .exe, .apk, SHA256SUMS.txt + .minisig)
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh
|
||||||
env:
|
env:
|
||||||
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||||
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
||||||
|
INK_NOTES_REQUIRE_APK: "1"
|
||||||
|
|
||||||
- name: Publish Release to Gitea
|
- name: Publish Release to Gitea
|
||||||
run: |
|
run: |
|
||||||
@@ -79,3 +88,58 @@ jobs:
|
|||||||
GITEA_SERVER: ${{ github.server_url }}
|
GITEA_SERVER: ${{ github.server_url }}
|
||||||
REPO_OWNER: ${{ github.repository_owner }}
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
REPO_NAME: ${{ github.event.repository.name }}
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
|
|
||||||
|
android:
|
||||||
|
# Builds the signed APK (Issue #45) and hands it to the release job as a workflow artifact
|
||||||
|
name: Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
|
- name: Install JDK and Tools
|
||||||
|
run: |
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
$SUDO apt-get update -y
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-17-jdk-headless build-essential pkg-config curl unzip zip
|
||||||
|
|
||||||
|
- name: Setup Rust Toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
target: aarch64-linux-android
|
||||||
|
cache: true
|
||||||
|
rustflags: ""
|
||||||
|
|
||||||
|
- name: Cache Android SDK
|
||||||
|
uses: actions/cache@v4.2.3
|
||||||
|
with:
|
||||||
|
path: ~/android-sdk
|
||||||
|
key: android-sdk-${{ hashFiles('scripts/setup-android-sdk.sh') }}
|
||||||
|
|
||||||
|
- name: Install Android SDK and NDK
|
||||||
|
run: ./scripts/setup-android-sdk.sh
|
||||||
|
|
||||||
|
- name: Build APK
|
||||||
|
run: |
|
||||||
|
if [ -n "${ANDROID_KEYSTORE_B64}" ]; then
|
||||||
|
KEYSTORE="${RUNNER_TEMP:-/tmp}/release.keystore"
|
||||||
|
printf '%s' "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE}"
|
||||||
|
export ANDROID_KEYSTORE="${KEYSTORE}"
|
||||||
|
fi
|
||||||
|
./scripts/package-android.sh
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||||
|
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
|
||||||
|
- name: Hand APK to the Release Job
|
||||||
|
uses: actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: ink-notes-android-apk
|
||||||
|
path: target/ink-notes_*_arm64.apk
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
*.pdf
|
*.pdf
|
||||||
export.pdf
|
export.pdf
|
||||||
|
|
||||||
|
# Android release signing key, never commit it
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
Generated
+249
-125
@@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
|
checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph_rasterizer",
|
"ab_glyph_rasterizer",
|
||||||
"owned_ttf_parser 0.25.1",
|
"owned_ttf_parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -24,6 +24,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
"cpubits",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -219,6 +230,24 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-sys"
|
name = "block-sys"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -259,17 +288,6 @@ dependencies = [
|
|||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bstr"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
"regex-automata",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -334,6 +352,15 @@ dependencies = [
|
|||||||
"wayland-client",
|
"wayland-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -370,6 +397,27 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.2.2",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -454,6 +502,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -500,6 +554,12 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -509,6 +569,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.2"
|
version = "1.5.2"
|
||||||
@@ -540,6 +609,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cursor-icon"
|
name = "cursor-icon"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -563,20 +641,25 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "deranged"
|
|
||||||
version = "0.5.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -630,6 +713,15 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecb"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecolor"
|
name = "ecolor"
|
||||||
version = "0.27.2"
|
version = "0.27.2"
|
||||||
@@ -966,6 +1058,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -975,7 +1068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"color_quant",
|
"color_quant",
|
||||||
"weezl",
|
"weezl 0.1.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1276,6 +1369,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icrate"
|
name = "icrate"
|
||||||
version = "0.0.4"
|
version = "0.0.4"
|
||||||
@@ -1438,7 +1540,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.39.1"
|
version = "0.41.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"android_logger",
|
"android_logger",
|
||||||
@@ -1454,18 +1556,19 @@ dependencies = [
|
|||||||
"hayro",
|
"hayro",
|
||||||
"hayro-svg",
|
"hayro-svg",
|
||||||
"image",
|
"image",
|
||||||
|
"jni",
|
||||||
"log",
|
"log",
|
||||||
|
"lopdf",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"naga",
|
"naga",
|
||||||
"octotablet",
|
"octotablet",
|
||||||
"pollster",
|
"pollster",
|
||||||
"printpdf",
|
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"resvg",
|
"resvg",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
@@ -1477,6 +1580,16 @@ dependencies = [
|
|||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||||
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1621,12 +1734,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linked-hash-map"
|
|
||||||
version = "0.5.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
@@ -1668,19 +1775,29 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lopdf"
|
name = "lopdf"
|
||||||
version = "0.31.0"
|
version = "0.46.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a"
|
checksum = "d43f64cb8c0d673ff84245a6de9e0172c0cc05dc5dd9d263660b5b4a02b6c4ae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"brotli-decompressor",
|
||||||
|
"cbc",
|
||||||
|
"ecb",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"indexmap",
|
||||||
"itoa",
|
"itoa",
|
||||||
"linked-hash-map",
|
|
||||||
"log",
|
"log",
|
||||||
"md5",
|
"md-5",
|
||||||
"pom",
|
"nom",
|
||||||
"time",
|
"rand",
|
||||||
"weezl",
|
"rangemap",
|
||||||
|
"sha2 0.11.1",
|
||||||
|
"stringprep",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"weezl 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1693,10 +1810,14 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md5"
|
name = "md-5"
|
||||||
version = "0.7.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
@@ -1827,10 +1948,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "nom"
|
||||||
version = "0.2.2"
|
version = "8.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
@@ -2012,22 +2136,13 @@ dependencies = [
|
|||||||
"libredox",
|
"libredox",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "owned_ttf_parser"
|
|
||||||
version = "0.19.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4"
|
|
||||||
dependencies = [
|
|
||||||
"ttf-parser 0.19.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "owned_ttf_parser"
|
name = "owned_ttf_parser"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
|
checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ttf-parser 0.25.1",
|
"ttf-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2196,15 +2311,6 @@ dependencies = [
|
|||||||
"arrayvec",
|
"arrayvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pom"
|
|
||||||
version = "3.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b"
|
|
||||||
dependencies = [
|
|
||||||
"bstr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -2214,30 +2320,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "powerfmt"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "presser"
|
name = "presser"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "printpdf"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"lopdf",
|
|
||||||
"owned_ttf_parser 0.19.0",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -2304,12 +2392,35 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "range-alloc"
|
name = "range-alloc"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rangemap"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -2371,12 +2482,6 @@ dependencies = [
|
|||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "regex-automata"
|
|
||||||
version = "0.4.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "renderdoc-sys"
|
name = "renderdoc-sys"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2602,8 +2707,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2744,6 +2860,17 @@ dependencies = [
|
|||||||
"float-cmp",
|
"float-cmp",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stringprep"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-bidi",
|
||||||
|
"unicode-normalization",
|
||||||
|
"unicode-properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strum"
|
name = "strum"
|
||||||
version = "0.26.3"
|
version = "0.26.3"
|
||||||
@@ -2898,40 +3025,10 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"half",
|
"half",
|
||||||
"quick-error",
|
"quick-error",
|
||||||
"weezl",
|
"weezl 0.1.12",
|
||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time"
|
|
||||||
version = "0.3.55"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
|
||||||
dependencies = [
|
|
||||||
"deranged",
|
|
||||||
"num-conv",
|
|
||||||
"powerfmt",
|
|
||||||
"serde_core",
|
|
||||||
"time-core",
|
|
||||||
"time-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time-core"
|
|
||||||
version = "0.1.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "time-macros"
|
|
||||||
version = "0.2.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
|
||||||
dependencies = [
|
|
||||||
"num-conv",
|
|
||||||
"time-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-skia"
|
name = "tiny-skia"
|
||||||
version = "0.11.4"
|
version = "0.11.4"
|
||||||
@@ -2993,6 +3090,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.2"
|
version = "1.53.2"
|
||||||
@@ -3032,12 +3135,6 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ttf-parser"
|
|
||||||
version = "0.19.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ttf-parser"
|
name = "ttf-parser"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -3059,12 +3156,33 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-bidi"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.26"
|
version = "1.0.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-normalization"
|
||||||
|
version = "0.1.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-properties"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.3"
|
version = "1.13.3"
|
||||||
@@ -3430,6 +3548,12 @@ version = "0.1.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu"
|
name = "wgpu"
|
||||||
version = "0.19.4"
|
version = "0.19.4"
|
||||||
|
|||||||
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.39.1"
|
version = "0.41.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
@@ -24,8 +24,8 @@ glam = { version = "0.27", features = ["serde"] }
|
|||||||
# Asynchroner Starter für wgpu-Device-Setup
|
# Asynchroner Starter für wgpu-Device-Setup
|
||||||
pollster = "0.3"
|
pollster = "0.3"
|
||||||
|
|
||||||
# PDF-Vektorexport
|
# PDF: writing the export (own writer in src/pdf_writer.rs) and merging imported PDFs
|
||||||
printpdf = "0.7"
|
lopdf = { version = "0.46", default-features = false }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
@@ -69,10 +69,12 @@ wayland-backend = { version = "0.3", features = ["client_system"] }
|
|||||||
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
|
||||||
|
|
||||||
# Android (Issue #42): winit bound to the native activity. The library is built as
|
# Android (Issue #42): winit bound to the native activity. The library is built as
|
||||||
# cdylib with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`
|
# cdylib and packed into an APK by scripts/package-android.sh
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
||||||
android_logger = { version = "0.13", default-features = false }
|
android_logger = { version = "0.13", default-features = false }
|
||||||
|
# Saving exports to Downloads through Android's MediaStore
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|
||||||
[package.metadata.android]
|
[package.metadata.android]
|
||||||
|
|||||||
+15
-4
@@ -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.
|
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“.
|
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).
|
- 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.
|
- 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
|
### 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.
|
- **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`).
|
- 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
|
## 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
|
```bash
|
||||||
# Debian-Paket (Zorin OS 18 / Ubuntu 24.04)
|
# 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.
|
**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
|
## Tastenkürzel
|
||||||
@@ -156,7 +163,7 @@ Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets --
|
|||||||
|
|
||||||
Andere Systeme:
|
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`).
|
- **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** (in Arbeit, Issue #42): `cargo check --target aarch64-linux-android` und `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android` mit dem NDK-Compiler in `CC_aarch64_linux_android`. Dort startet die App noch nicht: Die Fensterfläche muss erst dem Lebenszyklus der Activity folgen.
|
- **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
|
### Projektstruktur
|
||||||
|
|
||||||
@@ -179,6 +186,7 @@ src/
|
|||||||
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
├── assets.rs # Bilder und PDFs im Notizbuchordner
|
||||||
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
├── pdf_doc.rs # PDF lesen und rendern (hayro)
|
||||||
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen
|
||||||
|
├── pdf_writer.rs # Kleiner PDF-Schreiber für den Export (lopdf)
|
||||||
├── import.rs # Import als neues Notizbuch (Gemeinsames)
|
├── import.rs # Import als neues Notizbuch (Gemeinsames)
|
||||||
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
|
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
|
||||||
├── rnote.rs # Import von Rnote-Notizbüchern
|
├── rnote.rs # Import von Rnote-Notizbüchern
|
||||||
@@ -188,8 +196,11 @@ src/
|
|||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
||||||
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
||||||
├── library.rs # Ordner der Notizbücher (library.json)
|
├── 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
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export (printpdf)
|
├── export.rs # PDF-Export
|
||||||
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
|
||||||
├── file_picker.rs # Eingebaute Dateiauswahl
|
├── file_picker.rs # Eingebaute Dateiauswahl
|
||||||
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json)
|
├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json)
|
||||||
|
|||||||
@@ -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.
|
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".
|
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).
|
- 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.
|
- 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
|
### 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.
|
- **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`).
|
- **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
|
## 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
|
```bash
|
||||||
# Debian package (Zorin OS 18 / Ubuntu 24.04)
|
# 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.
|
**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
|
## Keyboard shortcuts
|
||||||
@@ -156,7 +163,7 @@ CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D
|
|||||||
|
|
||||||
Other systems:
|
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`).
|
- **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** (in progress, Issue #42): `cargo check --target aarch64-linux-android` and `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android` with the NDK compiler in `CC_aarch64_linux_android`. The app does not start there yet: the window surface still has to follow the activity lifecycle.
|
- **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.
|
New code comments and commit messages are written in English; older code and history still contain German.
|
||||||
|
|
||||||
@@ -181,6 +188,7 @@ src/
|
|||||||
├── assets.rs # Images and PDFs in the notebook folder
|
├── assets.rs # Images and PDFs in the notebook folder
|
||||||
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
├── pdf_doc.rs # Reading and rendering PDFs (hayro)
|
||||||
├── pdf_merge.rs # Underlaying imported PDF pages on export
|
├── pdf_merge.rs # Underlaying imported PDF pages on export
|
||||||
|
├── pdf_writer.rs # Small PDF writer for the export (lopdf)
|
||||||
├── import.rs # Import as a new notebook (shared parts)
|
├── import.rs # Import as a new notebook (shared parts)
|
||||||
├── xopp.rs # Import of Xournal++/Xournal notebooks
|
├── xopp.rs # Import of Xournal++/Xournal notebooks
|
||||||
├── rnote.rs # Import of Rnote notebooks
|
├── rnote.rs # Import of Rnote notebooks
|
||||||
@@ -190,8 +198,11 @@ src/
|
|||||||
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
||||||
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
||||||
├── library.rs # Notebook folders (library.json)
|
├── 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
|
├── autosave.rs # Saving in the background
|
||||||
├── export.rs # PDF export (printpdf)
|
├── export.rs # PDF export
|
||||||
├── page_export.rs # Page as PNG/SVG, selection as image
|
├── page_export.rs # Page as PNG/SVG, selection as image
|
||||||
├── file_picker.rs # Built-in file picker
|
├── file_picker.rs # Built-in file picker
|
||||||
├── settings.rs # Settings (~/.config/ink-notes/ui.json)
|
├── settings.rs # Settings (~/.config/ink-notes/ui.json)
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Highlight Card Gradient -->
|
||||||
|
<linearGradient id="highlightGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#132644"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1b32"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
<!-- Icon Hintergrund-Verlauf -->
|
<!-- Icon Hintergrund-Verlauf -->
|
||||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#1e2638"/>
|
<stop offset="0%" stop-color="#1e2638"/>
|
||||||
@@ -121,58 +127,98 @@
|
|||||||
|
|
||||||
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||||
<g transform="translate(360, 0)">
|
<g transform="translate(360, 0)">
|
||||||
<!-- Version & Platform Pill -->
|
<!-- Top Badges -->
|
||||||
<g transform="translate(0, 68)">
|
<g transform="translate(0, 56)">
|
||||||
<rect width="214" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
<!-- Platform Pill (No version number) -->
|
||||||
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
<rect width="248" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||||
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
<circle cx="15" cy="14" r="4" fill="#22c55e"/>
|
||||||
LINUX • RUST & WGPU
|
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="12" font-weight="600" fill="#cbd5e1" letter-spacing="0.6">
|
||||||
|
LINUX • WINDOWS • ANDROID
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<!-- Engine Pill -->
|
||||||
|
<g transform="translate(260, 0)">
|
||||||
|
<rect width="138" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||||
|
<text x="13" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="600" fill="#94a3b8" letter-spacing="0.5">
|
||||||
|
⚡ RUST & WGPU
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Major Update Pill -->
|
||||||
|
<g transform="translate(410, 0)">
|
||||||
|
<rect width="144" height="28" rx="14" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||||
|
<circle cx="15" cy="14" r="3.5" fill="#38bdf8"/>
|
||||||
|
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="700" fill="#38bdf8" letter-spacing="0.5">
|
||||||
|
MAJOR UPDATE
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Haupttitel: ink-notes -->
|
<!-- Haupttitel: ink-notes -->
|
||||||
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
<text x="0" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="58" letter-spacing="-1">
|
||||||
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Subtitle / Tagline -->
|
<!-- Subtitle / Tagline -->
|
||||||
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
<text x="0" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="20" fill="#94a3b8">
|
||||||
Notebook & PDF annotation app for pen, touch and mouse
|
Notebook & PDF annotation app for pen, touch and mouse
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Feature Pills / Highlights -->
|
<!-- Feature Highlights: Row 1 (Brand New Major Changes) -->
|
||||||
<g transform="translate(0, 248)">
|
<g transform="translate(0, 214)">
|
||||||
<!-- Pill 1: Niedrige Latenz -->
|
<!-- Pill 1: Windows & Android Support -->
|
||||||
<g transform="translate(0, 0)">
|
<g transform="translate(0, 0)">
|
||||||
<rect width="152" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="250" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||||
⚡ Fast wgpu inking
|
✨ Windows & Android Support
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Pill 2: Stylus mit Druck -->
|
<!-- Pill 2: New Import Functions -->
|
||||||
<g transform="translate(164, 0)">
|
<g transform="translate(262, 0)">
|
||||||
<rect width="214" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="348" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||||
🖊️ Pressure & palm rejection
|
📥 Import: Xournal++, Rnote, Saber & PDF
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Pill 3: Formerkennung -->
|
<!-- Pill 3: Fast Inking -->
|
||||||
<g transform="translate(390, 0)">
|
<g transform="translate(622, 0)">
|
||||||
<rect width="216" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="160" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
📐 Shape recognition & ruler
|
⚡ Fast wgpu Inking
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Feature Highlights: Row 2 (Core Strengths) -->
|
||||||
|
<g transform="translate(0, 262)">
|
||||||
|
<!-- Pill 4: Pressure & Palm Rejection with Vector Stylus Icon -->
|
||||||
|
<g transform="translate(0, 0)">
|
||||||
|
<rect width="248" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<svg x="14" y="9" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
|
||||||
|
</svg>
|
||||||
|
<text x="36" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
|
Pressure & Palm Rejection
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Pill 4: 100% lokal -->
|
<!-- Pill 5: Shape Recognition & Ruler -->
|
||||||
<g transform="translate(618, 0)">
|
<g transform="translate(260, 0)">
|
||||||
<rect width="188" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="254" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
🔒 100% local & atomic
|
📐 Shape Recognition & Ruler
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pill 6: 100% Local & Atomic -->
|
||||||
|
<g transform="translate(526, 0)">
|
||||||
|
<rect width="256" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
|
🔒 100% Local, Atomic & Private
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 11 KiB |
+75
-29
@@ -19,6 +19,12 @@
|
|||||||
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
<stop offset="100%" stop-color="#38bdf8" stop-opacity="0"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Highlight Card Gradient -->
|
||||||
|
<linearGradient id="highlightGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#132644"/>
|
||||||
|
<stop offset="100%" stop-color="#0d1b32"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
<!-- Icon Hintergrund-Verlauf -->
|
<!-- Icon Hintergrund-Verlauf -->
|
||||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" stop-color="#1e2638"/>
|
<stop offset="0%" stop-color="#1e2638"/>
|
||||||
@@ -121,58 +127,98 @@
|
|||||||
|
|
||||||
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
<!-- ==================== RECHTER BEREICH: TYPOGRAFIE & BADGES ==================== -->
|
||||||
<g transform="translate(360, 0)">
|
<g transform="translate(360, 0)">
|
||||||
<!-- Version & Platform Pill -->
|
<!-- Top Badges -->
|
||||||
<g transform="translate(0, 68)">
|
<g transform="translate(0, 56)">
|
||||||
<rect width="254" height="30" rx="15" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
<!-- Platform Pill (ohne Versionsnummer) -->
|
||||||
<circle cx="16" cy="15" r="4" fill="#22c55e"/>
|
<rect width="248" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||||
<text x="28" y="20" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#cbd5e1" letter-spacing="0.5">
|
<circle cx="15" cy="14" r="4" fill="#22c55e"/>
|
||||||
v0.14.0 • LINUX • RUST & WGPU
|
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="12" font-weight="600" fill="#cbd5e1" letter-spacing="0.6">
|
||||||
|
LINUX • WINDOWS • ANDROID
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<!-- Engine Pill -->
|
||||||
|
<g transform="translate(260, 0)">
|
||||||
|
<rect width="138" height="28" rx="14" fill="#1e293b" stroke="#334155" stroke-width="1.2"/>
|
||||||
|
<text x="13" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="600" fill="#94a3b8" letter-spacing="0.5">
|
||||||
|
⚡ RUST & WGPU
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Major Update Pill -->
|
||||||
|
<g transform="translate(410, 0)">
|
||||||
|
<rect width="164" height="28" rx="14" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||||
|
<circle cx="15" cy="14" r="3.5" fill="#38bdf8"/>
|
||||||
|
<text x="27" y="18.5" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="11.5" font-weight="700" fill="#38bdf8" letter-spacing="0.5">
|
||||||
|
GROSSES UPDATE
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Haupttitel: ink-notes -->
|
<!-- Haupttitel: ink-notes -->
|
||||||
<text x="0" y="162" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="64" letter-spacing="-1">
|
<text x="0" y="140" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="800" font-size="58" letter-spacing="-1">
|
||||||
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
<tspan fill="url(#textInkGrad)">ink</tspan><tspan fill="#f8fafc">-notes</tspan>
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Subtitle / Tagline -->
|
<!-- Subtitle / Tagline -->
|
||||||
<text x="0" y="206" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="22" fill="#94a3b8">
|
<text x="0" y="178" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-weight="400" font-size="20" fill="#94a3b8">
|
||||||
Notizbuch- & PDF-Annotations-App für Stift, Touch und Maus
|
Notizbuch- & PDF-Annotations-App für Stift, Touch und Maus
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<!-- Feature Pills / Highlights -->
|
<!-- Feature Highlights: Row 1 (Brandneue Hauptfeatures) -->
|
||||||
<g transform="translate(0, 248)">
|
<g transform="translate(0, 214)">
|
||||||
<!-- Pill 1: Niedrige Latenz -->
|
<!-- Pill 1: Windows & Android Support -->
|
||||||
<g transform="translate(0, 0)">
|
<g transform="translate(0, 0)">
|
||||||
<rect width="186" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="254" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||||
⚡ Schnelles wgpu-Inking
|
✨ Windows- & Android-Support
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Pill 2: Stylus mit Druck -->
|
<!-- Pill 2: Neue Import-Funktionen -->
|
||||||
<g transform="translate(198, 0)">
|
<g transform="translate(266, 0)">
|
||||||
<rect width="190" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="348" height="36" rx="10" fill="url(#highlightGrad)" stroke="#0ea5e9" stroke-width="1.2"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="600" fill="#7dd3fc">
|
||||||
🖊️ Druck & Palm Rejection
|
📥 Import: Xournal++, Rnote, Saber & PDF
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Pill 3: Formerkennung -->
|
<!-- Pill 3: Schnelles wgpu-Inking -->
|
||||||
<g transform="translate(400, 0)">
|
<g transform="translate(626, 0)">
|
||||||
<rect width="182" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="156" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
|
⚡ wgpu-Inking
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Feature Highlights: Row 2 (Kernfunktionen) -->
|
||||||
|
<g transform="translate(0, 262)">
|
||||||
|
<!-- Pill 4: Stiftdruck & Palm Rejection with Vector Stylus Icon -->
|
||||||
|
<g transform="translate(0, 0)">
|
||||||
|
<rect width="248" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<svg x="14" y="9" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
|
||||||
|
</svg>
|
||||||
|
<text x="36" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
|
Druck & Palm Rejection
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Pill 5: Formenerkennung & Lineal -->
|
||||||
|
<g transform="translate(260, 0)">
|
||||||
|
<rect width="254" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
|
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
📐 Formenerkennung & Lineal
|
📐 Formenerkennung & Lineal
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Pill 4: 100% lokal -->
|
<!-- Pill 6: 100% lokal, atomar & privat -->
|
||||||
<g transform="translate(594, 0)">
|
<g transform="translate(526, 0)">
|
||||||
<rect width="176" height="36" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
<rect width="256" height="34" rx="10" fill="#182236" stroke="#253554" stroke-width="1"/>
|
||||||
<text x="14" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13.5" font-weight="500" fill="#93c5fd">
|
<text x="14" y="22" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, sans-serif" font-size="13" font-weight="500" fill="#93c5fd">
|
||||||
🔒 100% lokal & atomar
|
🔒 100% lokal, atomar & privat
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 11 KiB |
@@ -189,13 +189,13 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers
|
|||||||
|
|
||||||
## 8. Export
|
## 8. Export
|
||||||
|
|
||||||
`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen:
|
`export.rs` erzeugt das PDF mit `pdf_writer.rs`, einem kleinen Schreiber auf Basis von `lopdf` (Ebenen als optionale Inhaltsgruppen, eingebettete Schriften mit ToUnicode-Tabelle, Bilder, Lesezeichen). Jede Seite bekommt ihre eigene Größe und diese Ebenen:
|
||||||
|
|
||||||
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier).
|
1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier).
|
||||||
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt.
|
||||||
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
3. **Images:** eingefügte Bilder (höchstens 300 dpi).
|
||||||
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet.
|
||||||
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt.
|
5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); `add_alpha_states` trägt sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt.
|
||||||
|
|
||||||
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen.
|
||||||
|
|
||||||
|
|||||||
@@ -189,13 +189,13 @@ Each page has its own history (`history.rs`): adding, removing, replacing and mo
|
|||||||
|
|
||||||
## 8. Export
|
## 8. Export
|
||||||
|
|
||||||
`export.rs` creates the PDF with `printpdf`. Each page gets its own size and these layers:
|
`export.rs` creates the PDF with `pdf_writer.rs`, a small writer on top of `lopdf` (layers as optional content groups, embedded fonts with a ToUnicode map, images, bookmarks). Each page gets its own size and these layers:
|
||||||
|
|
||||||
1. **Background:** squares, lines or dots in the chosen pattern color (always on white paper).
|
1. **Background:** squares, lines or dots in the chosen pattern color (always on white paper).
|
||||||
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
|
2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors.
|
||||||
3. **Images:** inserted images (at most 300 dpi).
|
3. **Images:** inserted images (at most 300 dpi).
|
||||||
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
|
4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners.
|
||||||
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before.
|
5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before.
|
||||||
|
|
||||||
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
|
Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Manifest of the Android app (Issue #42). scripts/package-android.sh fills in the version.
|
||||||
|
InkActivity (packaging/android/java) extends NativeActivity, which loads libink_notes.so;
|
||||||
|
its android_main runs the app. -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="eu.pansi.inknotes"
|
||||||
|
android:versionCode="@VERSION_CODE@"
|
||||||
|
android:versionName="@VERSION@">
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="ink-notes"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:hasCode="true"
|
||||||
|
android:extractNativeLibs="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<!-- configChanges: rotating the tablet or attaching a keyboard must not restart the
|
||||||
|
activity; the app adapts to the new window size itself -->
|
||||||
|
<activity
|
||||||
|
android:name="eu.pansi.inknotes.InkActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|density|uiMode|fontScale|locale|layoutDirection">
|
||||||
|
<meta-data android:name="android.app.lib_name" android:value="ink_notes" />
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Build environment for the Android APK (Issue #45): fixed versions of the JDK, Android SDK,
|
||||||
|
# NDK and Rust, so that the APK can be built the same way anywhere.
|
||||||
|
# Used by scripts/build-apk-container.sh (podman or docker).
|
||||||
|
FROM docker.io/library/ubuntu:24.04
|
||||||
|
|
||||||
|
ARG RUST_VERSION=1.99.0
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl unzip zip openjdk-17-jdk-headless build-essential pkg-config \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk \
|
||||||
|
RUSTUP_HOME=/opt/rustup \
|
||||||
|
CARGO_HOME=/opt/cargo \
|
||||||
|
PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
|
||||||
|
COPY scripts/setup-android-sdk.sh /tmp/setup-android-sdk.sh
|
||||||
|
RUN /tmp/setup-android-sdk.sh && rm /tmp/setup-android-sdk.sh
|
||||||
|
|
||||||
|
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal \
|
||||||
|
--default-toolchain "${RUST_VERSION}" --target aarch64-linux-android \
|
||||||
|
&& chmod -R a+rwX /opt/cargo /opt/rustup
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package eu.pansi.inknotes;
|
||||||
|
|
||||||
|
import android.app.NativeActivity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.text.InputType;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.inputmethod.BaseInputConnection;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
|
import android.view.inputmethod.InputConnection;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's activity (Issue #42): NativeActivity runs the Rust code (android_main in
|
||||||
|
* libink_notes.so); this class adds what NativeActivity lacks.
|
||||||
|
*
|
||||||
|
* - Text input: NativeActivity's view offers keyboards no input connection, so they can only
|
||||||
|
* send key events, and characters without a key (umlauts chosen by long press, ...) got lost.
|
||||||
|
* An invisible view with its own input connection passes committed text to Rust.
|
||||||
|
* - Picking files: the system file dialog (ACTION_OPEN_DOCUMENT); the chosen file is copied to
|
||||||
|
* the cache folder, as Rust can only open files by path.
|
||||||
|
*
|
||||||
|
* Rust calls showKeyboard and pickFile through JNI and receives the native* calls
|
||||||
|
* (src/android_bridge.rs).
|
||||||
|
*/
|
||||||
|
public class InkActivity extends NativeActivity {
|
||||||
|
static {
|
||||||
|
// NativeActivity loads the library too, but without System.loadLibrary the native
|
||||||
|
// methods below would not be found
|
||||||
|
System.loadLibrary("ink_notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int PICK_FILE = 1;
|
||||||
|
|
||||||
|
static native void nativeCommitText(String text);
|
||||||
|
|
||||||
|
static native void nativeDeleteSurroundingText(int before, int after);
|
||||||
|
|
||||||
|
/** Path of the copied file, or null if nothing was chosen or copying failed. */
|
||||||
|
static native void nativeFilePicked(String path);
|
||||||
|
|
||||||
|
private TextSink textSink;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
textSink = new TextSink(this);
|
||||||
|
addContentView(textSink, new ViewGroup.LayoutParams(1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows or hides the soft keyboard; called from Rust on any thread. */
|
||||||
|
public void showKeyboard(final boolean show) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
InputMethodManager imm =
|
||||||
|
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
if (show) {
|
||||||
|
textSink.requestFocus();
|
||||||
|
imm.restartInput(textSink);
|
||||||
|
imm.showSoftInput(textSink, 0);
|
||||||
|
} else {
|
||||||
|
imm.hideSoftInputFromWindow(textSink.getWindowToken(), 0);
|
||||||
|
textSink.clearFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the system file dialog for files of this MIME type ("image/*", "*" + "/*"). */
|
||||||
|
public void pickFile(final String mimeType) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
|
intent.setType(mimeType);
|
||||||
|
try {
|
||||||
|
startActivityForResult(intent, PICK_FILE);
|
||||||
|
} catch (Exception e) {
|
||||||
|
nativeFilePicked(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode != PICK_FILE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final Uri uri = (resultCode == RESULT_OK && data != null) ? data.getData() : null;
|
||||||
|
if (uri == null) {
|
||||||
|
nativeFilePicked(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Copying a large file must not block the UI thread
|
||||||
|
new Thread(() -> {
|
||||||
|
String path = null;
|
||||||
|
try {
|
||||||
|
path = copyToCache(uri);
|
||||||
|
} catch (Exception e) {
|
||||||
|
android.util.Log.w("ink-notes", "copying the picked file failed", e);
|
||||||
|
}
|
||||||
|
nativeFilePicked(path);
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String copyToCache(Uri uri) throws Exception {
|
||||||
|
String name = "import";
|
||||||
|
try (Cursor cursor = getContentResolver().query(
|
||||||
|
uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) {
|
||||||
|
if (cursor != null && cursor.moveToFirst() && !cursor.isNull(0)) {
|
||||||
|
name = cursor.getString(0).replace('/', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File dir = new File(getCacheDir(), "picked");
|
||||||
|
File[] old = dir.listFiles();
|
||||||
|
if (old != null) {
|
||||||
|
for (File f : old) {
|
||||||
|
f.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.mkdirs();
|
||||||
|
File target = new File(dir, name);
|
||||||
|
try (InputStream in = getContentResolver().openInputStream(uri);
|
||||||
|
OutputStream out = new FileOutputStream(target)) {
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
int n;
|
||||||
|
while ((n = in.read(buffer)) > 0) {
|
||||||
|
out.write(buffer, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target.getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invisible view that receives the keyboard's text. */
|
||||||
|
private static class TextSink extends View {
|
||||||
|
TextSink(Context context) {
|
||||||
|
super(context);
|
||||||
|
setFocusable(true);
|
||||||
|
setFocusableInTouchMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCheckIsTextEditor() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||||
|
// Visible password: keyboards commit every character right away instead of
|
||||||
|
// composing words with suggestions, which the app could not show
|
||||||
|
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
|
||||||
|
| InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||||
|
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
|
| InputType.TYPE_TEXT_FLAG_MULTI_LINE;
|
||||||
|
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||||
|
| EditorInfo.IME_FLAG_NO_FULLSCREEN
|
||||||
|
| EditorInfo.IME_ACTION_NONE;
|
||||||
|
return new TextConnection(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passes committed text and deletions to Rust; key events go the usual way. */
|
||||||
|
private static class TextConnection extends BaseInputConnection {
|
||||||
|
private String composing = "";
|
||||||
|
|
||||||
|
TextConnection(View view) {
|
||||||
|
super(view, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||||
|
composing = "";
|
||||||
|
if (text.length() > 0) {
|
||||||
|
nativeCommitText(text.toString());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean setComposingText(CharSequence text, int newCursorPosition) {
|
||||||
|
// Not shown while composing; taken over once finished
|
||||||
|
composing = text.toString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean finishComposingText() {
|
||||||
|
if (!composing.isEmpty()) {
|
||||||
|
nativeCommitText(composing);
|
||||||
|
composing = "";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
|
||||||
|
nativeDeleteSurroundingText(beforeLength, afterLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,26 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<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>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.39.2" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF export now uses its own small PDF writer on lopdf 0.46 instead of printpdf; the exported files look the same.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.39.1" date="2026-10-27">
|
<release version="0.39.1" date="2026-10-27">
|
||||||
<description>
|
<description>
|
||||||
<p>Windows: the mouse works again; Windows Ink no longer reports it as a pen.</p>
|
<p>Windows: the mouse works again; Windows Ink no longer reports it as a pen.</p>
|
||||||
|
|||||||
@@ -38,6 +38,19 @@
|
|||||||
"dest": "cargo/vendor/adler2-2.0.1",
|
"dest": "cargo/vendor/adler2-2.0.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/aes/aes-0.9.3.crate",
|
||||||
|
"sha256": "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32",
|
||||||
|
"dest": "cargo/vendor/aes-0.9.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/aes-0.9.3",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -337,6 +350,32 @@
|
|||||||
"dest": "cargo/vendor/block-buffer-0.10.4",
|
"dest": "cargo/vendor/block-buffer-0.10.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/block-buffer/block-buffer-0.12.1.crate",
|
||||||
|
"sha256": "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa",
|
||||||
|
"dest": "cargo/vendor/block-buffer-0.12.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/block-buffer-0.12.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/block-padding/block-padding-0.4.2.crate",
|
||||||
|
"sha256": "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b",
|
||||||
|
"dest": "cargo/vendor/block-padding-0.4.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/block-padding-0.4.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -493,6 +532,19 @@
|
|||||||
"dest": "cargo/vendor/calloop-wayland-source-0.2.0",
|
"dest": "cargo/vendor/calloop-wayland-source-0.2.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/cbc/cbc-0.2.1.crate",
|
||||||
|
"sha256": "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896",
|
||||||
|
"dest": "cargo/vendor/cbc-0.2.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/cbc-0.2.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -558,6 +610,32 @@
|
|||||||
"dest": "cargo/vendor/cfg_aliases-0.2.2",
|
"dest": "cargo/vendor/cfg_aliases-0.2.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/chacha20/chacha20-0.10.2.crate",
|
||||||
|
"sha256": "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06",
|
||||||
|
"dest": "cargo/vendor/chacha20-0.10.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/chacha20-0.10.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/cipher/cipher-0.5.2.crate",
|
||||||
|
"sha256": "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c",
|
||||||
|
"dest": "cargo/vendor/cipher-0.5.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/cipher-0.5.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -675,6 +753,19 @@
|
|||||||
"dest": "cargo/vendor/concurrent-queue-2.5.0",
|
"dest": "cargo/vendor/concurrent-queue-2.5.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/const-oid/const-oid-0.10.2.crate",
|
||||||
|
"sha256": "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c",
|
||||||
|
"dest": "cargo/vendor/const-oid-0.10.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/const-oid-0.10.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -740,6 +831,19 @@
|
|||||||
"dest": "cargo/vendor/core_detect-1.0.0",
|
"dest": "cargo/vendor/core_detect-1.0.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/cpubits/cpubits-0.1.1.crate",
|
||||||
|
"sha256": "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae",
|
||||||
|
"dest": "cargo/vendor/cpubits-0.1.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/cpubits-0.1.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -753,6 +857,19 @@
|
|||||||
"dest": "cargo/vendor/cpufeatures-0.2.17",
|
"dest": "cargo/vendor/cpufeatures-0.2.17",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/cpufeatures/cpufeatures-0.3.1.crate",
|
||||||
|
"sha256": "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566",
|
||||||
|
"dest": "cargo/vendor/cpufeatures-0.3.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/cpufeatures-0.3.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -805,6 +922,19 @@
|
|||||||
"dest": "cargo/vendor/crypto-common-0.1.7",
|
"dest": "cargo/vendor/crypto-common-0.1.7",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/crypto-common/crypto-common-0.2.2.crate",
|
||||||
|
"sha256": "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453",
|
||||||
|
"dest": "cargo/vendor/crypto-common-0.2.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/crypto-common-0.2.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -870,6 +1000,19 @@
|
|||||||
"dest": "cargo/vendor/digest-0.10.7",
|
"dest": "cargo/vendor/digest-0.10.7",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/digest/digest-0.11.3.crate",
|
||||||
|
"sha256": "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2",
|
||||||
|
"dest": "cargo/vendor/digest-0.11.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/digest-0.11.3",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -948,6 +1091,19 @@
|
|||||||
"dest": "cargo/vendor/downcast-rs-1.2.1",
|
"dest": "cargo/vendor/downcast-rs-1.2.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/ecb/ecb-0.2.1.crate",
|
||||||
|
"sha256": "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac",
|
||||||
|
"dest": "cargo/vendor/ecb-0.2.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/ecb-0.2.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1793,6 +1949,19 @@
|
|||||||
"dest": "cargo/vendor/home-0.5.12",
|
"dest": "cargo/vendor/home-0.5.12",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/hybrid-array/hybrid-array-0.4.15.crate",
|
||||||
|
"sha256": "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17",
|
||||||
|
"dest": "cargo/vendor/hybrid-array-0.4.15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/hybrid-array-0.4.15",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1975,6 +2144,19 @@
|
|||||||
"dest": "cargo/vendor/indexmap-2.14.2",
|
"dest": "cargo/vendor/indexmap-2.14.2",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/inout/inout-0.2.5.crate",
|
||||||
|
"sha256": "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f",
|
||||||
|
"dest": "cargo/vendor/inout-0.2.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/inout-0.2.5",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2261,6 +2443,19 @@
|
|||||||
"dest": "cargo/vendor/log-0.4.34",
|
"dest": "cargo/vendor/log-0.4.34",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/lopdf/lopdf-0.46.0.crate",
|
||||||
|
"sha256": "d43f64cb8c0d673ff84245a6de9e0172c0cc05dc5dd9d263660b5b4a02b6c4ae",
|
||||||
|
"dest": "cargo/vendor/lopdf-0.46.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"d43f64cb8c0d673ff84245a6de9e0172c0cc05dc5dd9d263660b5b4a02b6c4ae\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/lopdf-0.46.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2287,6 +2482,19 @@
|
|||||||
"dest": "cargo/vendor/malloc_buf-0.0.6",
|
"dest": "cargo/vendor/malloc_buf-0.0.6",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/md-5/md-5-0.11.0.crate",
|
||||||
|
"sha256": "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98",
|
||||||
|
"dest": "cargo/vendor/md-5-0.11.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/md-5-0.11.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -2469,6 +2677,19 @@
|
|||||||
"dest": "cargo/vendor/nohash-hasher-0.2.0",
|
"dest": "cargo/vendor/nohash-hasher-0.2.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/nom/nom-8.0.0.crate",
|
||||||
|
"sha256": "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405",
|
||||||
|
"dest": "cargo/vendor/nom-8.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/nom-8.0.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3158,6 +3379,32 @@
|
|||||||
"dest": "cargo/vendor/r-efi-6.0.0",
|
"dest": "cargo/vendor/r-efi-6.0.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/rand/rand-0.10.3.crate",
|
||||||
|
"sha256": "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af",
|
||||||
|
"dest": "cargo/vendor/rand-0.10.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/rand-0.10.3",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/rand_core/rand_core-0.10.1.crate",
|
||||||
|
"sha256": "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69",
|
||||||
|
"dest": "cargo/vendor/rand_core-0.10.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/rand_core-0.10.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3171,6 +3418,19 @@
|
|||||||
"dest": "cargo/vendor/range-alloc-0.1.5",
|
"dest": "cargo/vendor/range-alloc-0.1.5",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/rangemap/rangemap-1.8.0.crate",
|
||||||
|
"sha256": "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d",
|
||||||
|
"dest": "cargo/vendor/rangemap-1.8.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/rangemap-1.8.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3574,6 +3834,19 @@
|
|||||||
"dest": "cargo/vendor/sha2-0.10.9",
|
"dest": "cargo/vendor/sha2-0.10.9",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/sha2/sha2-0.11.1.crate",
|
||||||
|
"sha256": "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19",
|
||||||
|
"dest": "cargo/vendor/sha2-0.11.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/sha2-0.11.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3782,6 +4055,19 @@
|
|||||||
"dest": "cargo/vendor/strict-num-0.1.1",
|
"dest": "cargo/vendor/strict-num-0.1.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/stringprep/stringprep-0.1.5.crate",
|
||||||
|
"sha256": "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1",
|
||||||
|
"dest": "cargo/vendor/stringprep-0.1.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/stringprep-0.1.5",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4081,6 +4367,19 @@
|
|||||||
"dest": "cargo/vendor/tinystr-0.8.4",
|
"dest": "cargo/vendor/tinystr-0.8.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/tinyvec/tinyvec-1.13.3.crate",
|
||||||
|
"sha256": "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee",
|
||||||
|
"dest": "cargo/vendor/tinyvec-1.13.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/tinyvec-1.13.3",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4185,6 +4484,19 @@
|
|||||||
"dest": "cargo/vendor/typenum-1.20.1",
|
"dest": "cargo/vendor/typenum-1.20.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/unicode-bidi/unicode-bidi-0.3.18.crate",
|
||||||
|
"sha256": "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5",
|
||||||
|
"dest": "cargo/vendor/unicode-bidi-0.3.18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/unicode-bidi-0.3.18",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4198,6 +4510,32 @@
|
|||||||
"dest": "cargo/vendor/unicode-ident-1.0.26",
|
"dest": "cargo/vendor/unicode-ident-1.0.26",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/unicode-normalization/unicode-normalization-0.1.26.crate",
|
||||||
|
"sha256": "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d",
|
||||||
|
"dest": "cargo/vendor/unicode-normalization-0.1.26"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/unicode-normalization-0.1.26",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/unicode-properties/unicode-properties-0.1.4.crate",
|
||||||
|
"sha256": "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d",
|
||||||
|
"dest": "cargo/vendor/unicode-properties-0.1.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/unicode-properties-0.1.4",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4640,6 +4978,19 @@
|
|||||||
"dest": "cargo/vendor/weezl-0.1.12",
|
"dest": "cargo/vendor/weezl-0.1.12",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/weezl/weezl-0.2.1.crate",
|
||||||
|
"sha256": "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2",
|
||||||
|
"dest": "cargo/vendor/weezl-0.2.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/weezl-0.2.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
|
|||||||
@@ -84,6 +84,26 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<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>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.39.2" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF export now uses its own small PDF writer on lopdf 0.46 instead of printpdf; the exported files look the same.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.39.1" date="2026-10-27">
|
<release version="0.39.1" date="2026-10-27">
|
||||||
<description>
|
<description>
|
||||||
<p>Windows: the mouse works again; Windows Ink no longer reports it as a pen.</p>
|
<p>Windows: the mouse works again; Windows Ink no longer reports it as a pen.</p>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### 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
|
- **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
|
||||||
- **Windows (experimental)**: `ink-notes_<version>_x86_64.exe` is a portable program for Windows 10/11 – just start it, no installation. Notebooks and settings are stored in `%APPDATA%\ink-notes`, pen input uses Windows Ink, updates replace the `.exe` and take effect at the next start. Not yet tested on real tablet hardware – feedback welcome. Nothing changes on Linux; internally the groundwork for an Android version was laid
|
- **Windows (experimental)**: `ink-notes_<version>_x86_64.exe` is a portable program for Windows 10/11 – just start it, no installation. Notebooks and settings are stored in `%APPDATA%\ink-notes`, pen input uses Windows Ink, updates replace the `.exe` and take effect at the next start. Not yet tested on real tablet hardware – feedback welcome. Nothing changes on Linux; internally the groundwork for an Android version was laid
|
||||||
- **Sync between devices**: ☰ → Storage location puts the notebooks into a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox (existing notebooks are copied, the originals stay). Changes from other devices show up within seconds while everything local is saved, conflict copies of the sync client become pages of their own, and opening a notebook that is open on another device shows a warning
|
- **Sync between devices**: ☰ → Storage location puts the notebooks into a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox (existing notebooks are copied, the originals stay). Changes from other devices show up within seconds while everything local is saved, conflict copies of the sync client become pages of their own, and opening a notebook that is open on another device shows a warning
|
||||||
|
|||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Builds the APK inside the container from packaging/android/Containerfile (Issue #45),
|
||||||
|
# with podman (preferred) or docker. Needs nothing else on the host.
|
||||||
|
# Result: target/ink-notes_<version>_arm64.apk
|
||||||
|
#
|
||||||
|
# Signing key as in scripts/package-android.sh: ANDROID_KEYSTORE (path on the host),
|
||||||
|
# ANDROID_KEYSTORE_PASS, ANDROID_KEY_ALIAS; otherwise a debug key (kept below
|
||||||
|
# target/android-container/home, so that test builds can update each other).
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
ENGINE=podman
|
||||||
|
USER_ARGS=(--userns=keep-id)
|
||||||
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
|
ENGINE=docker
|
||||||
|
USER_ARGS=(--user "$(id -u):$(id -g)")
|
||||||
|
else
|
||||||
|
echo "FEHLER: weder podman noch docker gefunden."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE="ink-notes-android"
|
||||||
|
echo "=== Baue Container-Image ${IMAGE} ==="
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" -f packaging/android/Containerfile .
|
||||||
|
|
||||||
|
mkdir -p target/android-container/home
|
||||||
|
ARGS=(
|
||||||
|
--rm "${USER_ARGS[@]}"
|
||||||
|
-v "${ROOT_DIR}:/src"
|
||||||
|
-e HOME=/src/target/android-container/home
|
||||||
|
-e CARGO_TARGET_DIR=/src/target/android-container
|
||||||
|
# Downloaded crates stay between runs
|
||||||
|
-e CARGO_HOME=/src/target/android-container/cargo
|
||||||
|
)
|
||||||
|
if [ -n "${ANDROID_KEYSTORE:-}" ]; then
|
||||||
|
ARGS+=(
|
||||||
|
-v "$(realpath "${ANDROID_KEYSTORE}"):/keystore:ro"
|
||||||
|
-e ANDROID_KEYSTORE=/keystore
|
||||||
|
-e ANDROID_KEYSTORE_PASS -e ANDROID_KEY_ALIAS
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
"${ENGINE}" run "${ARGS[@]}" "${IMAGE}" scripts/package-android.sh
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
||||||
# Erstellt .deb, .AppImage, 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
|
|||||||
DEB_NAME="ink-notes_${VERSION}_amd64.deb"
|
DEB_NAME="ink-notes_${VERSION}_amd64.deb"
|
||||||
APPIMAGE_NAME="ink-notes_${VERSION}_x86_64.AppImage"
|
APPIMAGE_NAME="ink-notes_${VERSION}_x86_64.AppImage"
|
||||||
EXE_NAME="ink-notes_${VERSION}_x86_64.exe"
|
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"
|
CHECKSUM_FILE="SHA256SUMS.txt"
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
@@ -32,7 +34,14 @@ echo "======================================================================"
|
|||||||
# 4. Checksums berechnen
|
# 4. Checksums berechnen
|
||||||
echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
||||||
cd "${ROOT_DIR}/target"
|
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)
|
# 5. Prüfsummen signieren (Pflicht für In-App-Updates)
|
||||||
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
|
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
|
||||||
@@ -77,7 +86,7 @@ fi
|
|||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" "${CHECKSUM_FILE}"
|
ls -lh "${RELEASE_FILES[@]}" "${CHECKSUM_FILE}"
|
||||||
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
||||||
|
|||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Builds the Android app target/ink-notes_<version>_arm64.apk (Issues #42, #45)
|
||||||
|
# without Gradle: the Rust library is built as cdylib with the NDK, javac and d8 compile the
|
||||||
|
# activity (packaging/android/java), aapt2 links the manifest and the icon, zipalign and
|
||||||
|
# apksigner finish the APK. Needs a JDK (javac, keytool).
|
||||||
|
#
|
||||||
|
# Needs the Android SDK (build-tools, platform android-34) and the NDK:
|
||||||
|
# ANDROID_HOME SDK folder (default: ~/android-sdk)
|
||||||
|
# ANDROID_NDK_HOME NDK folder (default: newest in $ANDROID_HOME/ndk)
|
||||||
|
#
|
||||||
|
# Signing key (all three, otherwise the debug key ~/.android/debug.keystore is used and
|
||||||
|
# created if missing; such an APK is only meant for testing):
|
||||||
|
# ANDROID_KEYSTORE, ANDROID_KEYSTORE_PASS, ANDROID_KEY_ALIAS
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
TARGET="aarch64-linux-android"
|
||||||
|
ABI="arm64-v8a"
|
||||||
|
MIN_SDK=26
|
||||||
|
TARGET_SDK=34
|
||||||
|
VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
|
||||||
|
IFS=. read -r MAJOR MINOR PATCH <<<"${VERSION%%-*}"
|
||||||
|
VERSION_CODE=$((MAJOR * 10000 + MINOR * 100 + PATCH))
|
||||||
|
APK_NAME="ink-notes_${VERSION}_arm64.apk"
|
||||||
|
|
||||||
|
ANDROID_HOME="${ANDROID_HOME:-${HOME}/android-sdk}"
|
||||||
|
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||||
|
ANDROID_NDK_HOME=$(ls -d "${ANDROID_HOME}"/ndk/*/ 2>/dev/null | sort -V | tail -n1)
|
||||||
|
fi
|
||||||
|
BUILD_TOOLS=$(ls -d "${ANDROID_HOME}"/build-tools/*/ 2>/dev/null | sort -V | tail -n1)
|
||||||
|
ANDROID_JAR="${ANDROID_HOME}/platforms/android-${TARGET_SDK}/android.jar"
|
||||||
|
NDK_BIN="${ANDROID_NDK_HOME%/}/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||||
|
|
||||||
|
for needed in "${NDK_BIN}/${TARGET}${MIN_SDK}-clang" "${BUILD_TOOLS}aapt2" "${BUILD_TOOLS}d8" "${ANDROID_JAR}"; do
|
||||||
|
if [ ! -e "${needed}" ]; then
|
||||||
|
echo "FEHLER: ${needed} nicht gefunden (Android-SDK/NDK, siehe Kopf des Skripts)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! command -v javac >/dev/null 2>&1; then
|
||||||
|
echo "FEHLER: javac nicht gefunden (JDK 17 oder neuer)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
rustup target add "${TARGET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== [1/4] Baue libink_notes.so (${TARGET}) ==="
|
||||||
|
export CC_aarch64_linux_android="${NDK_BIN}/${TARGET}${MIN_SDK}-clang"
|
||||||
|
export AR_aarch64_linux_android="${NDK_BIN}/llvm-ar"
|
||||||
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${NDK_BIN}/${TARGET}${MIN_SDK}-clang"
|
||||||
|
cargo rustc --release --lib --crate-type cdylib --target "${TARGET}"
|
||||||
|
LIB="${CARGO_TARGET_DIR:-target}/${TARGET}/release/libink_notes.so"
|
||||||
|
|
||||||
|
echo "=== [2/4] Ressourcen und Manifest ==="
|
||||||
|
WORK="target/android-apk"
|
||||||
|
rm -rf "${WORK}"
|
||||||
|
mkdir -p "${WORK}/res/mipmap-xxxhdpi" "${WORK}/compiled" "${WORK}/apk/lib/${ABI}" "${WORK}/classes"
|
||||||
|
cp assets/ink-notes.png "${WORK}/res/mipmap-xxxhdpi/ic_launcher.png"
|
||||||
|
sed -e "s/@VERSION_CODE@/${VERSION_CODE}/" -e "s/@VERSION@/${VERSION}/" \
|
||||||
|
packaging/android/AndroidManifest.xml >"${WORK}/AndroidManifest.xml"
|
||||||
|
"${BUILD_TOOLS}aapt2" compile --dir "${WORK}/res" -o "${WORK}/compiled/res.zip"
|
||||||
|
"${BUILD_TOOLS}aapt2" link -o "${WORK}/unaligned.apk" -I "${ANDROID_JAR}" \
|
||||||
|
--manifest "${WORK}/AndroidManifest.xml" \
|
||||||
|
--min-sdk-version "${MIN_SDK}" --target-sdk-version "${TARGET_SDK}" \
|
||||||
|
"${WORK}/compiled/res.zip"
|
||||||
|
|
||||||
|
echo "=== [3/4] Activity und native Bibliothek hinzufügen ==="
|
||||||
|
javac -source 8 -target 8 -nowarn -Xlint:-options -classpath "${ANDROID_JAR}" \
|
||||||
|
-d "${WORK}/classes" $(find packaging/android/java -name '*.java')
|
||||||
|
"${BUILD_TOOLS}d8" --min-api "${MIN_SDK}" --lib "${ANDROID_JAR}" --output "${WORK}/apk" \
|
||||||
|
$(find "${WORK}/classes" -name '*.class')
|
||||||
|
cp "${LIB}" "${WORK}/apk/lib/${ABI}/"
|
||||||
|
(cd "${WORK}/apk" && zip -q -r ../unaligned.apk classes.dex lib)
|
||||||
|
"${BUILD_TOOLS}zipalign" -f -p 4 "${WORK}/unaligned.apk" "${WORK}/aligned.apk"
|
||||||
|
|
||||||
|
echo "=== [4/4] Signieren ==="
|
||||||
|
if [ -n "${ANDROID_KEYSTORE:-}" ] && [ -n "${ANDROID_KEYSTORE_PASS:-}" ] && [ -n "${ANDROID_KEY_ALIAS:-}" ]; then
|
||||||
|
KEYSTORE="${ANDROID_KEYSTORE}"
|
||||||
|
KEY_ALIAS="${ANDROID_KEY_ALIAS}"
|
||||||
|
export KS_PASS="${ANDROID_KEYSTORE_PASS}"
|
||||||
|
else
|
||||||
|
KEYSTORE="${HOME}/.android/debug.keystore"
|
||||||
|
KEY_ALIAS="androiddebugkey"
|
||||||
|
export KS_PASS="android"
|
||||||
|
if [ ! -f "${KEYSTORE}" ]; then
|
||||||
|
mkdir -p "$(dirname "${KEYSTORE}")"
|
||||||
|
keytool -genkeypair -keystore "${KEYSTORE}" -storepass android -keypass android \
|
||||||
|
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-dname "CN=Android Debug,O=Android,C=US"
|
||||||
|
fi
|
||||||
|
echo "Hinweis: Signiert mit dem Debug-Schlüssel ${KEYSTORE} (nur zum Testen)."
|
||||||
|
fi
|
||||||
|
"${BUILD_TOOLS}apksigner" sign --ks "${KEYSTORE}" --ks-key-alias "${KEY_ALIAS}" \
|
||||||
|
--ks-pass env:KS_PASS --key-pass env:KS_PASS \
|
||||||
|
--out "target/${APK_NAME}" "${WORK}/aligned.apk"
|
||||||
|
"${BUILD_TOOLS}apksigner" verify "target/${APK_NAME}"
|
||||||
|
ls -lh "target/${APK_NAME}"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
||||||
# Lädt .deb, .AppImage, .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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
|
|||||||
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
||||||
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
||||||
EXE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.exe"
|
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"
|
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
||||||
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
|
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
|
||||||
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
||||||
@@ -238,6 +239,9 @@ except Exception:
|
|||||||
upload_asset "${DEB_FILE}"
|
upload_asset "${DEB_FILE}"
|
||||||
upload_asset "${APPIMAGE_FILE}"
|
upload_asset "${APPIMAGE_FILE}"
|
||||||
upload_asset "${EXE_FILE}"
|
upload_asset "${EXE_FILE}"
|
||||||
|
if [ -f "${APK_FILE}" ]; then
|
||||||
|
upload_asset "${APK_FILE}"
|
||||||
|
fi
|
||||||
upload_asset "${CHECKSUMS_FILE}"
|
upload_asset "${CHECKSUMS_FILE}"
|
||||||
upload_asset "${SIGNATURE_FILE}"
|
upload_asset "${SIGNATURE_FILE}"
|
||||||
|
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Installs the Android SDK parts that scripts/package-android.sh needs, in fixed versions
|
||||||
|
# (Issue #45): command-line tools, platform 34, build-tools 34.0.0 and NDK 28.2.
|
||||||
|
# Used by the CI and packaging/android/Containerfile; works for a local SDK as well.
|
||||||
|
#
|
||||||
|
# ANDROID_HOME target folder (default: ~/android-sdk)
|
||||||
|
#
|
||||||
|
# Needs curl, unzip and a JDK 17 or newer (sdkmanager is a Java program).
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ANDROID_HOME="${ANDROID_HOME:-${HOME}/android-sdk}"
|
||||||
|
CMDLINE_TOOLS_ZIP="commandlinetools-linux-11076708_latest.zip"
|
||||||
|
CMDLINE_TOOLS_SHA256="2d2d50857e4eb553af5a6dc3ad507a17adf43d115264b1afc116f95c92e5e258"
|
||||||
|
PACKAGES=(
|
||||||
|
"platforms;android-34"
|
||||||
|
"build-tools;34.0.0"
|
||||||
|
"ndk;28.2.13676358"
|
||||||
|
)
|
||||||
|
|
||||||
|
SDKMANAGER="${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
if [ ! -x "${SDKMANAGER}" ]; then
|
||||||
|
echo "=== Lade Android Command-line Tools ==="
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP}"' EXIT
|
||||||
|
curl -sSfL -o "${TMP}/${CMDLINE_TOOLS_ZIP}" \
|
||||||
|
"https://dl.google.com/android/repository/${CMDLINE_TOOLS_ZIP}"
|
||||||
|
echo "${CMDLINE_TOOLS_SHA256} ${TMP}/${CMDLINE_TOOLS_ZIP}" | sha256sum -c -
|
||||||
|
unzip -q "${TMP}/${CMDLINE_TOOLS_ZIP}" -d "${TMP}"
|
||||||
|
mkdir -p "${ANDROID_HOME}/cmdline-tools"
|
||||||
|
rm -rf "${ANDROID_HOME}/cmdline-tools/latest"
|
||||||
|
mv "${TMP}/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Installiere ${PACKAGES[*]} nach ${ANDROID_HOME} ==="
|
||||||
|
# sdkmanager ends with an error when `yes` is cut off by a closed pipe; that's fine
|
||||||
|
(yes || true) | "${SDKMANAGER}" --sdk_root="${ANDROID_HOME}" --licenses >/dev/null
|
||||||
|
"${SDKMANAGER}" --sdk_root="${ANDROID_HOME}" --install "${PACKAGES[@]}" >/dev/null
|
||||||
|
echo "Android-SDK bereit: ${ANDROID_HOME}"
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Android: calls into the app's Java activity and what it reports back (Issue #42).
|
||||||
|
//!
|
||||||
|
//! `packaging/android/java/eu/pansi/inknotes/InkActivity.java` extends NativeActivity with
|
||||||
|
//! text input through an input connection (so that keyboards can send any character) and the
|
||||||
|
//! system file dialog. Rust calls its methods through JNI; it calls the `native*` functions
|
||||||
|
//! below, possibly on another thread, so they only queue messages and wake the event loop.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use jni::objects::{JObject, JString, JValue};
|
||||||
|
use jni::sys::jint;
|
||||||
|
use jni::JNIEnv;
|
||||||
|
|
||||||
|
use crate::file_picker::PickerKind;
|
||||||
|
use crate::ui::UiAction;
|
||||||
|
|
||||||
|
enum Message {
|
||||||
|
Text(String),
|
||||||
|
Delete { before: u32, after: u32 },
|
||||||
|
Picked(Option<PathBuf>),
|
||||||
|
}
|
||||||
|
|
||||||
|
static MESSAGES: Mutex<Vec<Message>> = Mutex::new(Vec::new());
|
||||||
|
/// What the open file dialog is for
|
||||||
|
static PICKING: Mutex<Option<PickerKind>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn push(message: Message) {
|
||||||
|
MESSAGES
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.push(message);
|
||||||
|
if let Some(app) = crate::platform::android_app() {
|
||||||
|
app.create_waker().wake();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeCommitText(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JObject,
|
||||||
|
text: JString,
|
||||||
|
) {
|
||||||
|
if let Ok(text) = env.get_string(&text) {
|
||||||
|
push(Message::Text(text.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeDeleteSurroundingText(
|
||||||
|
_env: JNIEnv,
|
||||||
|
_class: JObject,
|
||||||
|
before: jint,
|
||||||
|
after: jint,
|
||||||
|
) {
|
||||||
|
push(Message::Delete {
|
||||||
|
before: before.max(0) as u32,
|
||||||
|
after: after.max(0) as u32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_eu_pansi_inknotes_InkActivity_nativeFilePicked(
|
||||||
|
mut env: JNIEnv,
|
||||||
|
_class: JObject,
|
||||||
|
path: JString,
|
||||||
|
) {
|
||||||
|
let path = if path.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
env.get_string(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|p| PathBuf::from(String::from(p)))
|
||||||
|
};
|
||||||
|
push(Message::Picked(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is a message waiting (then the app draws a new frame to take it)?
|
||||||
|
pub fn has_messages() -> bool {
|
||||||
|
!MESSAGES
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed text and deletions as egui input, and the action for a picked file.
|
||||||
|
pub fn take_messages() -> (Vec<egui::Event>, Vec<UiAction>) {
|
||||||
|
let messages = std::mem::take(&mut *MESSAGES.lock().unwrap_or_else(|e| e.into_inner()));
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
for message in messages {
|
||||||
|
match message {
|
||||||
|
Message::Text(text) => events.extend(crate::osk::committed_text_events(&text)),
|
||||||
|
Message::Delete { before, after } => {
|
||||||
|
events.extend(crate::osk::deletion_key_events(before, after))
|
||||||
|
}
|
||||||
|
Message::Picked(path) => {
|
||||||
|
let kind = PICKING.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||||
|
match (kind, path) {
|
||||||
|
(Some(PickerKind::Image), Some(path)) => {
|
||||||
|
actions.push(UiAction::InsertImageFile(path))
|
||||||
|
}
|
||||||
|
(Some(PickerKind::Import), Some(path)) => {
|
||||||
|
actions.push(UiAction::ImportFile(path))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(events, actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f` with the JNI environment and the activity; local references it creates are
|
||||||
|
/// freed afterwards.
|
||||||
|
fn with_activity(
|
||||||
|
f: impl FnOnce(&mut JNIEnv, &JObject) -> jni::errors::Result<()>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app = crate::platform::android_app().ok_or("no activity")?;
|
||||||
|
// Safety: pointers to the VM and the activity (a global reference) that live as long as
|
||||||
|
// the app
|
||||||
|
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
|
||||||
|
let mut env = vm
|
||||||
|
.attach_current_thread_permanently()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||||
|
let result = env.with_local_frame(16, |env| f(env, &activity));
|
||||||
|
if env.exception_check().unwrap_or(false) {
|
||||||
|
let _ = env.exception_describe();
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
}
|
||||||
|
result.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_keyboard(show: bool) {
|
||||||
|
let result = with_activity(|env, activity| {
|
||||||
|
env.call_method(
|
||||||
|
activity,
|
||||||
|
"showKeyboard",
|
||||||
|
"(Z)V",
|
||||||
|
&[JValue::Bool(show.into())],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
});
|
||||||
|
if let Err(err) = result {
|
||||||
|
log::warn!("showKeyboard: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the system file dialog instead of the app's own; false if that is not possible
|
||||||
|
/// (e.g. for choosing a folder).
|
||||||
|
pub fn pick_file(kind: PickerKind) -> bool {
|
||||||
|
let mime_type = match kind {
|
||||||
|
PickerKind::Image => "image/*",
|
||||||
|
// Xournal++, Rnote and Saber files have no MIME type of their own
|
||||||
|
PickerKind::Import => "*/*",
|
||||||
|
PickerKind::Folder => return false,
|
||||||
|
};
|
||||||
|
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = Some(kind);
|
||||||
|
let result = with_activity(|env, activity| {
|
||||||
|
let mime = env.new_string(mime_type)?;
|
||||||
|
env.call_method(
|
||||||
|
activity,
|
||||||
|
"pickFile",
|
||||||
|
"(Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(&mime)],
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("pickFile: {err}");
|
||||||
|
*PICKING.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1947
-751
File diff suppressed because it is too large.
Load diff
+43
-5
@@ -165,7 +165,10 @@ struct NeighborPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct CanvasState {
|
pub struct CanvasState {
|
||||||
pub surface: wgpu::Surface<'static>,
|
/// Drawing surface of the window; `None` while the app is in the background on Android
|
||||||
|
/// (the native window only exists between `Resumed` and `Suspended`)
|
||||||
|
surface: Option<wgpu::Surface<'static>>,
|
||||||
|
instance: wgpu::Instance,
|
||||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||||
adapter: wgpu::Adapter,
|
adapter: wgpu::Adapter,
|
||||||
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
/// Multisampling der Zeichenfläche (1 = aus) und das zugehörige Zwischenbild
|
||||||
@@ -591,7 +594,8 @@ impl CanvasState {
|
|||||||
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
|
let egui_renderer = egui_wgpu::Renderer::new(&device, render_format(&config), None, 1);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
surface,
|
surface: Some(surface),
|
||||||
|
instance,
|
||||||
adapter,
|
adapter,
|
||||||
msaa_samples,
|
msaa_samples,
|
||||||
msaa_view,
|
msaa_view,
|
||||||
@@ -727,14 +731,42 @@ impl CanvasState {
|
|||||||
self.update_uniforms();
|
self.update_uniforms();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases the drawing surface: on Android the native window goes away when the app
|
||||||
|
/// is sent to the background (`Event::Suspended`). Drawing is skipped until [`Self::resume`].
|
||||||
|
pub fn suspend(&mut self) {
|
||||||
|
self.surface = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the drawing surface again for the window (`Event::Resumed` on Android).
|
||||||
|
pub fn resume(&mut self, window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if self.surface.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let surface = self.instance.create_surface(window.clone())?;
|
||||||
|
if !surface
|
||||||
|
.get_capabilities(&self.adapter)
|
||||||
|
.formats
|
||||||
|
.contains(&self.config.format)
|
||||||
|
{
|
||||||
|
return Err("surface format changed".into());
|
||||||
|
}
|
||||||
|
self.surface = Some(surface);
|
||||||
|
// Configures the new surface (the window may have changed size meanwhile)
|
||||||
|
self.resize(window.inner_size());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||||
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||||
if new_size.width > 0 && new_size.height > 0 {
|
if new_size.width > 0 && new_size.height > 0 {
|
||||||
self.size = new_size;
|
self.size = new_size;
|
||||||
self.config.width = new_size.width;
|
self.config.width = new_size.width;
|
||||||
self.config.height = new_size.height;
|
self.config.height = new_size.height;
|
||||||
|
let Some(surface) = &self.surface else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
||||||
let caps = self.surface.get_capabilities(&self.adapter);
|
let caps = surface.get_capabilities(&self.adapter);
|
||||||
if !caps.present_modes.contains(&self.config.present_mode) {
|
if !caps.present_modes.contains(&self.config.present_mode) {
|
||||||
match choose_present_mode(&caps.present_modes) {
|
match choose_present_mode(&caps.present_modes) {
|
||||||
Some(mode) => self.config.present_mode = mode,
|
Some(mode) => self.config.present_mode = mode,
|
||||||
@@ -750,7 +782,7 @@ impl CanvasState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.surface.configure(&self.device, &self.config);
|
surface.configure(&self.device, &self.config);
|
||||||
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
self.msaa_view = create_msaa_view(&self.device, &self.config, self.msaa_samples);
|
||||||
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
|
self.stencil_view = create_stencil_view(&self.device, &self.config, self.msaa_samples);
|
||||||
self.recalculate_viewport();
|
self.recalculate_viewport();
|
||||||
@@ -1386,7 +1418,13 @@ impl CanvasState {
|
|||||||
.update_texture(&self.device, &self.queue, *id, image_delta);
|
.update_texture(&self.device, &self.queue, *id, image_delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = self.surface.get_current_texture()?;
|
let Some(surface) = &self.surface else {
|
||||||
|
for id in &textures_delta.free {
|
||||||
|
self.egui_renderer.free_texture(id);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let output = surface.get_current_texture()?;
|
||||||
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
let view = output.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
format: Some(render_format(&self.config)),
|
format: Some(render_format(&self.config)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+22
-3
@@ -19,7 +19,21 @@ use crate::storage::{self, NotebookManifest, OpenMarker, PageData};
|
|||||||
/// Time between two checks.
|
/// Time between two checks.
|
||||||
pub const INTERVAL: Duration = Duration::from_secs(3);
|
pub const INTERVAL: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
type Fingerprint = (Option<SystemTime>, u64);
|
/// Modification time, size and, for small files, a hash of the content.
|
||||||
|
type Fingerprint = (Option<SystemTime>, u64, Option<u64>);
|
||||||
|
|
||||||
|
/// Files up to this size are also compared by content: file times are only as fine as the
|
||||||
|
/// kernel clock tick, so a file rewritten right after a check with the same length (e.g. a
|
||||||
|
/// manifest with one page replaced by another) would otherwise look unchanged.
|
||||||
|
const HASHED_FILE_SIZE: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
fn content_hash(path: &Path) -> Option<u64> {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let bytes = fs::read(path).ok()?;
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
bytes.hash(&mut hasher);
|
||||||
|
Some(hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
/// What a check found and took over.
|
/// What a check found and took over.
|
||||||
#[derive(Debug, Default, Clone, PartialEq)]
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
@@ -61,8 +75,13 @@ fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let meta = entry.metadata().ok()?;
|
let meta = entry.metadata().ok()?;
|
||||||
meta.is_file()
|
if !meta.is_file() {
|
||||||
.then(|| (name, (meta.modified().ok(), meta.len())))
|
return None;
|
||||||
|
}
|
||||||
|
let hash = (meta.len() <= HASHED_FILE_SIZE)
|
||||||
|
.then(|| content_hash(&entry.path()))
|
||||||
|
.flatten();
|
||||||
|
Some((name, (meta.modified().ok(), meta.len(), hash)))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-130
@@ -1,4 +1,4 @@
|
|||||||
//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs via printpdf.
|
//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs (PDF-Ausgabe: [`crate::pdf_writer`]).
|
||||||
//!
|
//!
|
||||||
//! Features:
|
//! Features:
|
||||||
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
||||||
//! - Zwei Export-Strategien:
|
//! - Zwei Export-Strategien:
|
||||||
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
|
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
|
||||||
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line`
|
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `pdf_writer::Line`
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -16,14 +16,15 @@ use std::fs::File;
|
|||||||
use std::io::BufWriter;
|
use std::io::BufWriter;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use printpdf::path::{PaintMode, WindingOrder};
|
|
||||||
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
|
|
||||||
|
|
||||||
use crate::assets::AssetStore;
|
use crate::assets::AssetStore;
|
||||||
use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING};
|
use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING};
|
||||||
use crate::document::{
|
use crate::document::{
|
||||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
|
use crate::pdf_writer::{
|
||||||
|
Color, Line, Mm, PaintMode, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb,
|
||||||
|
WindingOrder,
|
||||||
|
};
|
||||||
use crate::t;
|
use crate::t;
|
||||||
use ab_glyph::Font as _;
|
use ab_glyph::Font as _;
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ pub fn sanitize_filename_component(title: &str) -> String {
|
|||||||
/// `~/.config/user-dirs.dirs` (z. B. „Dokumente“), sonst `~/Documents`. Im Flatpak ist nur
|
/// `~/.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.
|
/// dieser Ordner beschreibbar, der Fallback allein träfe bei deutschen Systemen daneben.
|
||||||
pub fn default_documents_dir() -> PathBuf {
|
pub fn default_documents_dir() -> PathBuf {
|
||||||
|
// Android: written into the app's own folder first, then copied to Downloads
|
||||||
|
// (`platform::publish_export`)
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if let Some(dir) = crate::platform::android_app().and_then(|app| app.external_data_path()) {
|
||||||
|
return dir.join("Documents");
|
||||||
|
}
|
||||||
|
|
||||||
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||||
if !dir.trim().is_empty() {
|
if !dir.trim().is_empty() {
|
||||||
return PathBuf::from(dir);
|
return PathBuf::from(dir);
|
||||||
@@ -147,19 +155,19 @@ fn pdf_page_mm(page: &Page) -> (Mm, Mm) {
|
|||||||
|
|
||||||
/// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe
|
/// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe
|
||||||
/// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben.
|
/// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben.
|
||||||
fn begin_page_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
|
fn begin_page_layer(layer: &crate::pdf_writer::PdfLayerReference, page: &Page) {
|
||||||
layer.save_graphics_state();
|
layer.save_graphics_state();
|
||||||
let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT;
|
let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT;
|
||||||
if dy != 0.0 {
|
if dy != 0.0 {
|
||||||
layer.set_ctm(printpdf::CurTransMat::Translate(
|
layer.set_ctm(crate::pdf_writer::CurTransMat::Translate(
|
||||||
printpdf::Pt(0.0),
|
crate::pdf_writer::Pt(0.0),
|
||||||
printpdf::Pt(dy),
|
crate::pdf_writer::Pt(dy),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Beendet eine mit [`begin_page_layer`] begonnene Ebene.
|
/// Beendet eine mit [`begin_page_layer`] begonnene Ebene.
|
||||||
fn end_page_layer(layer: &printpdf::PdfLayerReference) {
|
fn end_page_layer(layer: &crate::pdf_writer::PdfLayerReference) {
|
||||||
layer.restore_graphics_state();
|
layer.restore_graphics_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +219,7 @@ pub fn export_notebook_to_bytes(
|
|||||||
return export_notebook_to_bytes(&cropped, strategy, assets, pattern);
|
return export_notebook_to_bytes(&cropped, strategy, assets, pattern);
|
||||||
}
|
}
|
||||||
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
|
||||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
let bytes = add_alpha_states(doc.save_to_bytes()?, notebook)?;
|
||||||
|
|
||||||
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||||
let underlays = pdf_underlays(notebook);
|
let underlays = pdf_underlays(notebook);
|
||||||
@@ -237,7 +245,7 @@ pub fn export_notebook_to_bytes(
|
|||||||
raster.insert(underlay.target_page);
|
raster.insert(underlay.target_page);
|
||||||
}
|
}
|
||||||
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
|
||||||
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
|
let bytes = add_alpha_states(doc.save_to_bytes()?, notebook)?;
|
||||||
let remaining: Vec<_> = underlays
|
let remaining: Vec<_> = underlays
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|u| !raster.contains(&u.target_page))
|
.filter(|u| !raster.contains(&u.target_page))
|
||||||
@@ -249,35 +257,6 @@ pub fn export_notebook_to_bytes(
|
|||||||
Ok(merged)
|
Ok(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
|
|
||||||
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
|
|
||||||
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
|
|
||||||
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
|
||||||
let needs_fix = notebook
|
|
||||||
.pages
|
|
||||||
.iter()
|
|
||||||
.filter_map(|p| p.title.as_deref())
|
|
||||||
.any(|t| !t.is_ascii());
|
|
||||||
if !needs_fix {
|
|
||||||
return Ok(bytes);
|
|
||||||
}
|
|
||||||
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
|
||||||
for object in doc.objects.values_mut() {
|
|
||||||
let Ok(dict) = object.as_dict_mut() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
|
|
||||||
if let Some(utf16) = utf16be_text(raw) {
|
|
||||||
*raw = utf16;
|
|
||||||
*format = printpdf::lopdf::StringFormat::Hexadecimal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut out = Vec::new();
|
|
||||||
doc.save_to(&mut out)?;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
|
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
|
||||||
/// gerundet); `None` für deckende Farben.
|
/// gerundet); `None` für deckende Farben.
|
||||||
fn alpha_state_name(alpha: f32) -> Option<String> {
|
fn alpha_state_name(alpha: f32) -> Option<String> {
|
||||||
@@ -285,12 +264,12 @@ fn alpha_state_name(alpha: f32) -> Option<String> {
|
|||||||
(percent < 100).then(|| format!("InkAlpha{percent}"))
|
(percent < 100).then(|| format!("InkAlpha{percent}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
|
/// Grafikzustände mit Deckkraft: Die Tintenebene verweist mit
|
||||||
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
|
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
|
||||||
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
|
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
|
||||||
/// unverändert.
|
/// unverändert.
|
||||||
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
use printpdf::lopdf::{Dictionary, Object};
|
use lopdf::{Dictionary, Object};
|
||||||
|
|
||||||
let mut states: Vec<(String, f32)> = notebook
|
let mut states: Vec<(String, f32)> = notebook
|
||||||
.pages
|
.pages
|
||||||
@@ -305,7 +284,7 @@ fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<
|
|||||||
states.sort_by(|a, b| a.0.cmp(&b.0));
|
states.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
states.dedup_by(|a, b| a.0 == b.0);
|
states.dedup_by(|a, b| a.0 == b.0);
|
||||||
|
|
||||||
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
|
let mut doc = lopdf::Document::load_mem(&bytes)?;
|
||||||
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
|
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
|
||||||
for page_id in page_ids {
|
for page_id in page_ids {
|
||||||
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
|
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
|
||||||
@@ -328,11 +307,11 @@ fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<
|
|||||||
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
|
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
|
||||||
/// Wörterbuch wird dafür zu einem eigenen Objekt.
|
/// Wörterbuch wird dafür zu einem eigenen Objekt.
|
||||||
fn dict_object_id(
|
fn dict_object_id(
|
||||||
doc: &mut printpdf::lopdf::Document,
|
doc: &mut lopdf::Document,
|
||||||
owner: printpdf::lopdf::ObjectId,
|
owner: lopdf::ObjectId,
|
||||||
key: &[u8],
|
key: &[u8],
|
||||||
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
|
) -> Result<lopdf::ObjectId, Box<dyn Error>> {
|
||||||
use printpdf::lopdf::{Dictionary, Object};
|
use lopdf::{Dictionary, Object};
|
||||||
|
|
||||||
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
|
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
|
||||||
if let Some(Object::Reference(id)) = entry {
|
if let Some(Object::Reference(id)) = entry {
|
||||||
@@ -349,20 +328,6 @@ fn dict_object_id(
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
|
|
||||||
/// ungültigem UTF-8.
|
|
||||||
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
|
|
||||||
let text = std::str::from_utf8(raw).ok()?;
|
|
||||||
if text.is_ascii() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut out = vec![0xFE, 0xFF];
|
|
||||||
for unit in text.encode_utf16() {
|
|
||||||
out.extend_from_slice(&unit.to_be_bytes());
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
||||||
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||||
notebook
|
notebook
|
||||||
@@ -387,7 +352,7 @@ fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
|
/// Generiert das `crate::pdf_writer::PdfDocumentReference` für alle Seiten des Notizbuchs.
|
||||||
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
|
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
|
||||||
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
|
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
|
||||||
pub fn generate_pdf_document(
|
pub fn generate_pdf_document(
|
||||||
@@ -447,7 +412,7 @@ fn generate_pdf_document_with(
|
|||||||
}
|
}
|
||||||
let page_ref = doc.get_page(page_idx);
|
let page_ref = doc.get_page(page_idx);
|
||||||
render_background_layer(&page_ref.get_layer(layer_bg), page, pattern);
|
render_background_layer(&page_ref.get_layer(layer_bg), page, pattern);
|
||||||
let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| {
|
let layer = |name: &str, render: &dyn Fn(&crate::pdf_writer::PdfLayerReference)| {
|
||||||
let layer = page_ref.add_layer(name);
|
let layer = page_ref.add_layer(name);
|
||||||
begin_page_layer(&layer, page);
|
begin_page_layer(&layer, page);
|
||||||
render(&layer);
|
render(&layer);
|
||||||
@@ -470,7 +435,7 @@ fn generate_pdf_document_with(
|
|||||||
|
|
||||||
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
||||||
fn render_background_layer(
|
fn render_background_layer(
|
||||||
layer: &printpdf::PdfLayerReference,
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
pattern: PatternColor,
|
pattern: PatternColor,
|
||||||
) {
|
) {
|
||||||
@@ -481,7 +446,7 @@ fn render_background_layer(
|
|||||||
.color(crate::background::PaperTheme::White)
|
.color(crate::background::PaperTheme::White)
|
||||||
.map(crate::background::linear_to_srgb);
|
.map(crate::background::linear_to_srgb);
|
||||||
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
|
||||||
layer.set_outline_color(grid_color.clone());
|
layer.set_outline_color(grid_color);
|
||||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||||
layer.set_fill_color(grid_color);
|
layer.set_fill_color(grid_color);
|
||||||
|
|
||||||
@@ -567,7 +532,11 @@ fn render_background_layer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||||
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
fn render_ink_layer(
|
||||||
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
|
page: &Page,
|
||||||
|
strategy: ExportStrategy,
|
||||||
|
) {
|
||||||
for stroke in &page.strokes {
|
for stroke in &page.strokes {
|
||||||
if stroke.is_object() {
|
if stroke.is_object() {
|
||||||
continue;
|
continue;
|
||||||
@@ -578,9 +547,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
|
|||||||
let alpha_state = alpha_state_name(stroke.color[3]);
|
let alpha_state = alpha_state_name(stroke.color[3]);
|
||||||
if let Some(name) = &alpha_state {
|
if let Some(name) = &alpha_state {
|
||||||
layer.save_graphics_state();
|
layer.save_graphics_state();
|
||||||
layer.add_operation(printpdf::lopdf::content::Operation::new(
|
layer.add_operation(lopdf::content::Operation::new(
|
||||||
"gs",
|
"gs",
|
||||||
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
|
vec![lopdf::Object::Name(name.as_bytes().to_vec())],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(ref shape) = stroke.shape {
|
if let Some(ref shape) = stroke.shape {
|
||||||
@@ -648,8 +617,8 @@ fn pdf_image_xobject(
|
|||||||
assets: &AssetStore,
|
assets: &AssetStore,
|
||||||
asset: &str,
|
asset: &str,
|
||||||
corners: &[glam::Vec2; 4],
|
corners: &[glam::Vec2; 4],
|
||||||
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
|
) -> Result<crate::pdf_writer::ImageXObject, Box<dyn Error>> {
|
||||||
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
|
use crate::pdf_writer::{ColorSpace, ImageFilter, ImageXObject, Px};
|
||||||
|
|
||||||
let bytes = assets.read_bytes(asset)?;
|
let bytes = assets.read_bytes(asset)?;
|
||||||
let decoded = image::load_from_memory(&bytes)?;
|
let decoded = image::load_from_memory(&bytes)?;
|
||||||
@@ -665,12 +634,9 @@ fn pdf_image_xobject(
|
|||||||
width: Px(decoded.width() as usize),
|
width: Px(decoded.width() as usize),
|
||||||
height: Px(decoded.height() as usize),
|
height: Px(decoded.height() as usize),
|
||||||
color_space,
|
color_space,
|
||||||
bits_per_component: ColorBits::Bit8,
|
|
||||||
interpolate: true,
|
interpolate: true,
|
||||||
image_data: bytes,
|
image_data: bytes,
|
||||||
image_filter: Some(ImageFilter::DCT),
|
image_filter: Some(ImageFilter::DCT),
|
||||||
smask: None,
|
|
||||||
clipping_bbox: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -700,7 +666,7 @@ fn pdf_image_xobject(
|
|||||||
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
|
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
|
||||||
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
|
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
|
||||||
fn render_image_layer(
|
fn render_image_layer(
|
||||||
layer: &printpdf::PdfLayerReference,
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
assets: Option<&AssetStore>,
|
assets: Option<&AssetStore>,
|
||||||
) {
|
) {
|
||||||
@@ -725,7 +691,7 @@ fn render_image_layer(
|
|||||||
fn embed_text_fonts(
|
fn embed_text_fonts(
|
||||||
doc: &PdfDocumentReference,
|
doc: &PdfDocumentReference,
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
) -> Result<HashMap<TextFont, printpdf::IndirectFontRef>, Box<dyn Error>> {
|
) -> Result<HashMap<TextFont, crate::pdf_writer::IndirectFontRef>, Box<dyn Error>> {
|
||||||
let mut fonts = HashMap::new();
|
let mut fonts = HashMap::new();
|
||||||
let used = notebook
|
let used = notebook
|
||||||
.pages
|
.pages
|
||||||
@@ -744,9 +710,9 @@ fn embed_text_fonts(
|
|||||||
/// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine
|
/// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine
|
||||||
/// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung.
|
/// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung.
|
||||||
fn render_text_layer(
|
fn render_text_layer(
|
||||||
layer: &printpdf::PdfLayerReference,
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
fonts: &HashMap<TextFont, printpdf::IndirectFontRef>,
|
fonts: &HashMap<TextFont, crate::pdf_writer::IndirectFontRef>,
|
||||||
) {
|
) {
|
||||||
for stroke in &page.strokes {
|
for stroke in &page.strokes {
|
||||||
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
|
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
|
||||||
@@ -763,7 +729,7 @@ fn render_text_layer(
|
|||||||
let o = page_to_pdf_pt(origin);
|
let o = page_to_pdf_pt(origin);
|
||||||
let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y];
|
let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y];
|
||||||
layer.save_graphics_state();
|
layer.save_graphics_state();
|
||||||
layer.set_ctm(printpdf::CurTransMat::Raw(matrix));
|
layer.set_ctm(crate::pdf_writer::CurTransMat::Raw(matrix));
|
||||||
layer.set_fill_color(Color::Rgb(Rgb::new(
|
layer.set_fill_color(Color::Rgb(Rgb::new(
|
||||||
stroke.color[0],
|
stroke.color[0],
|
||||||
stroke.color[1],
|
stroke.color[1],
|
||||||
@@ -776,9 +742,9 @@ fn render_text_layer(
|
|||||||
if line.text.trim().is_empty() {
|
if line.text.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
layer.set_text_matrix(printpdf::TextMatrix::Translate(
|
layer.set_text_matrix(crate::pdf_writer::TextMatrix::Translate(
|
||||||
printpdf::Pt(0.0),
|
crate::pdf_writer::Pt(0.0),
|
||||||
printpdf::Pt(-layout.baseline(i)),
|
crate::pdf_writer::Pt(-layout.baseline(i)),
|
||||||
));
|
));
|
||||||
// Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em)
|
// Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em)
|
||||||
let face = text.font.face();
|
let face = text.font.face();
|
||||||
@@ -796,45 +762,31 @@ fn render_text_layer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Graue 1×1-Fläche für fehlende Bilder.
|
/// Graue 1×1-Fläche für fehlende Bilder.
|
||||||
fn placeholder_xobject() -> printpdf::ImageXObject {
|
fn placeholder_xobject() -> crate::pdf_writer::ImageXObject {
|
||||||
rgb_xobject(1, 1, vec![200, 200, 200])
|
rgb_xobject(1, 1, vec![200, 200, 200])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> printpdf::ImageXObject {
|
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> crate::pdf_writer::ImageXObject {
|
||||||
printpdf::ImageXObject {
|
crate::pdf_writer::ImageXObject {
|
||||||
width: printpdf::Px(width as usize),
|
width: crate::pdf_writer::Px(width as usize),
|
||||||
height: printpdf::Px(height as usize),
|
height: crate::pdf_writer::Px(height as usize),
|
||||||
color_space: printpdf::ColorSpace::Rgb,
|
color_space: crate::pdf_writer::ColorSpace::Rgb,
|
||||||
bits_per_component: printpdf::ColorBits::Bit8,
|
|
||||||
interpolate: true,
|
interpolate: true,
|
||||||
image_data: rgb,
|
image_data: rgb,
|
||||||
image_filter: None,
|
image_filter: None,
|
||||||
smask: None,
|
|
||||||
clipping_bbox: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
|
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
|
||||||
/// unten links ausfüllt.
|
/// unten links ausfüllt.
|
||||||
fn draw_image_xobject(
|
fn draw_image_xobject(
|
||||||
layer: &printpdf::PdfLayerReference,
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
xobject: printpdf::ImageXObject,
|
xobject: crate::pdf_writer::ImageXObject,
|
||||||
corners: &[glam::Vec2; 4],
|
corners: &[glam::Vec2; 4],
|
||||||
) {
|
) {
|
||||||
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
|
|
||||||
layer.save_graphics_state();
|
layer.save_graphics_state();
|
||||||
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners)));
|
layer.set_ctm(crate::pdf_writer::CurTransMat::Raw(image_matrix(corners)));
|
||||||
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
|
layer.add_image(xobject);
|
||||||
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
|
|
||||||
printpdf::Image::from(xobject).add_to_layer(
|
|
||||||
layer.clone(),
|
|
||||||
printpdf::ImageTransform {
|
|
||||||
dpi: Some(72.0),
|
|
||||||
scale_x: Some(1.0 / width),
|
|
||||||
scale_y: Some(1.0 / height),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
layer.restore_graphics_state();
|
layer.restore_graphics_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +796,7 @@ const PDF_RASTER_DPI: f32 = 200.0;
|
|||||||
|
|
||||||
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
|
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
|
||||||
fn render_pdf_raster(
|
fn render_pdf_raster(
|
||||||
layer: &printpdf::PdfLayerReference,
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
assets: Option<&AssetStore>,
|
assets: Option<&AssetStore>,
|
||||||
) {
|
) {
|
||||||
@@ -882,19 +834,19 @@ fn render_pdf_raster(
|
|||||||
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
||||||
/// als verlustfreien Vektorpfad im PDF.
|
/// als verlustfreien Vektorpfad im PDF.
|
||||||
fn render_recognized_shape(
|
fn render_recognized_shape(
|
||||||
layer: &printpdf::PdfLayerReference,
|
layer: &crate::pdf_writer::PdfLayerReference,
|
||||||
shape: &crate::geometry::RecognizedShape,
|
shape: &crate::geometry::RecognizedShape,
|
||||||
color: [f32; 4],
|
color: [f32; 4],
|
||||||
) {
|
) {
|
||||||
use crate::geometry::RecognizedShape;
|
use crate::geometry::RecognizedShape;
|
||||||
|
|
||||||
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
|
||||||
layer.set_outline_color(stroke_color.clone());
|
layer.set_outline_color(stroke_color);
|
||||||
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
|
||||||
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
|
||||||
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
|
||||||
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
|
layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Butt);
|
||||||
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
|
layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Miter);
|
||||||
|
|
||||||
match shape {
|
match shape {
|
||||||
RecognizedShape::Line { start, end } => {
|
RecognizedShape::Line { start, end } => {
|
||||||
@@ -1178,7 +1130,7 @@ fn render_recognized_shape(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Strategie A: Gefülltes Polygon aus den Triangle-Strip-Hüllpunkten.
|
/// Strategie A: Gefülltes Polygon aus den Triangle-Strip-Hüllpunkten.
|
||||||
fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
fn render_stroke_polygon(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) {
|
||||||
if stroke.vertices.is_empty() {
|
if stroke.vertices.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1266,8 +1218,8 @@ fn exact_outlines() -> bool {
|
|||||||
*EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0"))
|
*EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via crate::pdf_writer::Line.
|
||||||
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
fn render_stroke_bezier(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) {
|
||||||
if stroke.points.is_empty() {
|
if stroke.points.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1279,8 +1231,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
layer.set_outline_color(outline_color);
|
layer.set_outline_color(outline_color);
|
||||||
layer.set_line_cap_style(printpdf::LineCapStyle::Round);
|
layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Round);
|
||||||
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
|
layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Round);
|
||||||
|
|
||||||
// Durchschnittliche Linienstärke aus dem Druck berechnen
|
// Durchschnittliche Linienstärke aus dem Druck berechnen
|
||||||
let avg_pressure =
|
let avg_pressure =
|
||||||
@@ -1753,10 +1705,10 @@ mod tests {
|
|||||||
PatternColor::default(),
|
PatternColor::default(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
let doc = lopdf::Document::load_mem(&bytes).unwrap();
|
||||||
assert_eq!(doc.get_pages().len(), 2);
|
assert_eq!(doc.get_pages().len(), 2);
|
||||||
for page_id in doc.get_pages().into_values() {
|
for page_id in doc.get_pages().into_values() {
|
||||||
let content = doc.get_page_content(page_id).unwrap();
|
let content = doc.get_page_content(page_id);
|
||||||
assert!(
|
assert!(
|
||||||
String::from_utf8_lossy(&content).starts_with("q "),
|
String::from_utf8_lossy(&content).starts_with("q "),
|
||||||
"Originalseite fehlt als Unterlage"
|
"Originalseite fehlt als Unterlage"
|
||||||
@@ -1864,7 +1816,7 @@ mod tests {
|
|||||||
PatternColor::default(),
|
PatternColor::default(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
let doc = lopdf::Document::load_mem(&bytes).unwrap();
|
||||||
let catalog = doc.catalog().unwrap();
|
let catalog = doc.catalog().unwrap();
|
||||||
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
|
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
|
||||||
|
|
||||||
@@ -1875,17 +1827,15 @@ mod tests {
|
|||||||
.filter_map(|o| o.as_dict().ok())
|
.filter_map(|o| o.as_dict().ok())
|
||||||
.filter(|d| d.has(b"Parent"))
|
.filter(|d| d.has(b"Parent"))
|
||||||
.filter_map(|d| match d.get(b"Title") {
|
.filter_map(|d| match d.get(b"Title") {
|
||||||
Ok(printpdf::lopdf::Object::String(raw, _)) => {
|
Ok(lopdf::Object::String(raw, _)) => Some(if raw.starts_with(&[0xFE, 0xFF]) {
|
||||||
Some(if raw.starts_with(&[0xFE, 0xFF]) {
|
let units: Vec<u16> = raw[2..]
|
||||||
let units: Vec<u16> = raw[2..]
|
.chunks(2)
|
||||||
.chunks(2)
|
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
.collect();
|
||||||
.collect();
|
String::from_utf16(&units).unwrap()
|
||||||
String::from_utf16(&units).unwrap()
|
} else {
|
||||||
} else {
|
String::from_utf8(raw.clone()).unwrap()
|
||||||
String::from_utf8(raw.clone()).unwrap()
|
}),
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
|
||||||
("Fehler: {err}", "Error: {err}"),
|
("Fehler: {err}", "Error: {err}"),
|
||||||
("Fehlermeldung: {message}", "Error message: {message}"),
|
("Fehlermeldung: {message}", "Error message: {message}"),
|
||||||
|
("Formeln nach „=“ ausrechnen", "Compute formulas after “=”"),
|
||||||
("Formen (Shape)", "Shapes"),
|
("Formen (Shape)", "Shapes"),
|
||||||
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
("Formen: Striche werden zu Linien, Rechtecken, Kreisen … (S/F)", "Shapes: strokes become lines, rectangles, circles … (S/F)"),
|
||||||
("Fünfeck (Pentagon)", "Pentagon"),
|
("Fünfeck (Pentagon)", "Pentagon"),
|
||||||
@@ -186,6 +187,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Mindestdruck", "Minimum pressure"),
|
("Mindestdruck", "Minimum pressure"),
|
||||||
("Mit dem Finger zeichnen", "Draw with finger"),
|
("Mit dem Finger zeichnen", "Draw with finger"),
|
||||||
("Mittel", "Medium"),
|
("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 …"),
|
("Name …", "Name …"),
|
||||||
("Neon-Gelb", "Neon yellow"),
|
("Neon-Gelb", "Neon yellow"),
|
||||||
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
("Netzwerk-Transportfehler: {t}", "Network transport error: {t}"),
|
||||||
|
|||||||
+11
-3
@@ -1,5 +1,7 @@
|
|||||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub mod android_bridge;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
@@ -16,11 +18,16 @@ pub mod i18n;
|
|||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod math;
|
||||||
|
pub mod math_assist;
|
||||||
|
pub mod math_ink;
|
||||||
pub mod osk;
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod pdf_doc;
|
pub mod pdf_doc;
|
||||||
pub mod pdf_merge;
|
pub mod pdf_merge;
|
||||||
|
pub mod pdf_writer;
|
||||||
|
pub mod pen;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod rnote;
|
pub mod rnote;
|
||||||
pub mod ruler;
|
pub mod ruler;
|
||||||
@@ -37,9 +44,7 @@ pub mod xopp;
|
|||||||
|
|
||||||
/// Entry point on Android (Issue #42), called by the native activity.
|
/// Entry point on Android (Issue #42), called by the native activity.
|
||||||
///
|
///
|
||||||
/// Builds with `cargo rustc --lib --crate-type cdylib --target aarch64-linux-android`. Not
|
/// `scripts/package-android.sh` builds the library and packs it into an APK.
|
||||||
/// usable yet: the window surface still has to be created on `Resumed` and dropped on
|
|
||||||
/// `Suspended`.
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||||
@@ -50,6 +55,9 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
|||||||
.with_max_level(log::LevelFilter::Info)
|
.with_max_level(log::LevelFilter::Info)
|
||||||
.with_tag("ink-notes"),
|
.with_tag("ink-notes"),
|
||||||
);
|
);
|
||||||
|
// Panics would otherwise only go to stderr, which Android discards
|
||||||
|
std::panic::set_hook(Box::new(|info| log::error!("{info}")));
|
||||||
|
platform::set_android_app(app.clone());
|
||||||
if let Some(dir) = app.internal_data_path() {
|
if let Some(dir) = app.internal_data_path() {
|
||||||
paths::set_app_dir(dir);
|
paths::set_app_dir(dir);
|
||||||
}
|
}
|
||||||
|
|||||||
+757
@@ -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(°ree(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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+71
-3
@@ -17,11 +17,39 @@ mod wayland;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use wayland::KeyboardRequester;
|
pub use wayland::KeyboardRequester;
|
||||||
|
|
||||||
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
/// Android: the soft keyboard is shown only when asked to (Issue #42). Its text comes through
|
||||||
#[cfg(not(target_os = "linux"))]
|
/// the activity's input connection (`android_bridge`); keys like Enter or Backspace may also
|
||||||
|
/// arrive as key events through winit.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
pub struct KeyboardRequester;
|
pub struct KeyboardRequester;
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(target_os = "android")]
|
||||||
|
impl KeyboardRequester {
|
||||||
|
/// # Safety
|
||||||
|
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||||
|
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
|
||||||
|
Some(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(&mut self) {
|
||||||
|
crate::android_bridge::show_keyboard(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(&mut self) {
|
||||||
|
crate::android_bridge::show_keyboard(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed text and deletions; taking them is left to the app's frame (see `app.rs`)
|
||||||
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elsewhere the system shows the on-screen keyboard by itself; nothing to request.
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
|
pub struct KeyboardRequester;
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
impl KeyboardRequester {
|
impl KeyboardRequester {
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// Nothing to uphold; `unsafe` like the Wayland version.
|
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||||
@@ -31,6 +59,8 @@ impl KeyboardRequester {
|
|||||||
|
|
||||||
pub fn request(&mut self) {}
|
pub fn request(&mut self) {}
|
||||||
|
|
||||||
|
pub fn release(&mut self) {}
|
||||||
|
|
||||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -75,6 +105,30 @@ pub fn deletion_key_events(back: u32, forward: u32) -> Vec<egui::Event> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// egui input for committed text: line breaks become the Enter key, as egui's text fields
|
||||||
|
/// take them only that way.
|
||||||
|
pub fn committed_text_events(text: &str) -> Vec<egui::Event> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
for (i, line) in text.split('\n').enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
for pressed in [true, false] {
|
||||||
|
events.push(egui::Event::Key {
|
||||||
|
key: egui::Key::Enter,
|
||||||
|
physical_key: None,
|
||||||
|
pressed,
|
||||||
|
repeat: false,
|
||||||
|
modifiers: egui::Modifiers::NONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line = line.trim_end_matches('\r');
|
||||||
|
if !line.is_empty() {
|
||||||
|
events.push(egui::Event::Text(line.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
|
/// Entscheidet, ob die Bildschirmtastatur angefordert wird: sobald ein Textfeld den Fokus
|
||||||
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
|
/// bekommt und erneut bei jedem Antippen des fokussierten Felds (falls die Tastatur inzwischen
|
||||||
/// zugeklappt wurde).
|
/// zugeklappt wurde).
|
||||||
@@ -165,6 +219,20 @@ mod tests {
|
|||||||
assert!(deletion_key_events(0, 0).is_empty());
|
assert!(deletion_key_events(0, 0).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn committed_line_breaks_become_enter() {
|
||||||
|
let events = committed_text_events("ab\nc\n");
|
||||||
|
let kinds: Vec<String> = events
|
||||||
|
.iter()
|
||||||
|
.map(|e| match e {
|
||||||
|
egui::Event::Text(t) => t.clone(),
|
||||||
|
egui::Event::Key { pressed: true, .. } => "⏎".into(),
|
||||||
|
_ => "-".into(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kinds, ["ab", "⏎", "-", "c", "⏎", "-"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_request_without_focused_text_field() {
|
fn no_request_without_focused_text_field() {
|
||||||
assert!(!should_request(false, false, true));
|
assert!(!should_request(false, false, true));
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ impl KeyboardRequester {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text field lost focus. Nothing to do: the compositor hides the keyboard once
|
||||||
|
/// winit disables text input.
|
||||||
|
pub fn release(&mut self) {}
|
||||||
|
|
||||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
||||||
/// aufrufen und an die egui-Eingabe anhängen)
|
/// aufrufen und an die egui-Eingabe anhängen)
|
||||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
|
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
|
||||||
//! Notizen (Text bleibt scharf und auswählbar).
|
//! Notizen (Text bleibt scharf und auswählbar).
|
||||||
//!
|
//!
|
||||||
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
|
//! Vorgehen: Das vom Export erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
|
||||||
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
|
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
|
||||||
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
|
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
use lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
||||||
|
|
||||||
use crate::t;
|
use crate::t;
|
||||||
|
|
||||||
@@ -361,7 +361,7 @@ mod tests {
|
|||||||
2,
|
2,
|
||||||
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
|
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
|
||||||
);
|
);
|
||||||
let first = doc.get_page_content(pages[0]).unwrap();
|
let first = doc.get_page_content(pages[0]);
|
||||||
let text = String::from_utf8_lossy(&first);
|
let text = String::from_utf8_lossy(&first);
|
||||||
assert!(
|
assert!(
|
||||||
text.starts_with("q "),
|
text.starts_with("q "),
|
||||||
|
|||||||
@@ -0,0 +1,879 @@
|
|||||||
|
//! Small PDF writer on top of `lopdf` for the vector export.
|
||||||
|
//!
|
||||||
|
//! Replaces printpdf 0.7, which still depended on lopdf 0.31. The interface follows the part
|
||||||
|
//! of printpdf that [`crate::export`] used: a document with pages, each page with named
|
||||||
|
//! layers (optional content groups, shown as layers in PDF viewers) that collect drawing
|
||||||
|
//! operations, embedded TrueType fonts for real, searchable text, images and bookmarks.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ab_glyph::Font as _;
|
||||||
|
use lopdf::content::{Content, Operation};
|
||||||
|
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat};
|
||||||
|
|
||||||
|
/// Length in millimetres.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||||
|
pub struct Mm(pub f32);
|
||||||
|
|
||||||
|
/// Length in PDF points (1/72 inch).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||||
|
pub struct Pt(pub f32);
|
||||||
|
|
||||||
|
impl From<Mm> for Pt {
|
||||||
|
fn from(mm: Mm) -> Self {
|
||||||
|
Pt(mm.0 * 72.0 / 25.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size in pixels.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Px(pub usize);
|
||||||
|
|
||||||
|
/// Point on the page (origin bottom left).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Point {
|
||||||
|
pub x: Pt,
|
||||||
|
pub y: Pt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Point {
|
||||||
|
pub fn new(x: Mm, y: Mm) -> Self {
|
||||||
|
Self {
|
||||||
|
x: x.into(),
|
||||||
|
y: y.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open or closed polyline; a point marked `true` followed by another marked `true` starts a
|
||||||
|
/// cubic Bézier segment (the two are its control points, the next one its end point).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Line {
|
||||||
|
pub points: Vec<(Point, bool)>,
|
||||||
|
pub is_closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a polygon is painted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PaintMode {
|
||||||
|
Fill,
|
||||||
|
Stroke,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum WindingOrder {
|
||||||
|
NonZero,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closed shape of one or more rings (same point marking as [`Line`]).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Polygon {
|
||||||
|
pub rings: Vec<Vec<(Point, bool)>>,
|
||||||
|
pub mode: PaintMode,
|
||||||
|
pub winding_order: WindingOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Rgb {
|
||||||
|
pub r: f32,
|
||||||
|
pub g: f32,
|
||||||
|
pub b: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rgb {
|
||||||
|
/// The last argument (ICC profile in printpdf) is ignored.
|
||||||
|
pub fn new(r: f32, g: f32, b: f32, _icc: Option<()>) -> Self {
|
||||||
|
Self { r, g, b }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Color {
|
||||||
|
Rgb(Rgb),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change of the coordinate system (`cm`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum CurTransMat {
|
||||||
|
Translate(Pt, Pt),
|
||||||
|
Raw([f32; 6]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text position within a text section (`Tm`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum TextMatrix {
|
||||||
|
Translate(Pt, Pt),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LineCapStyle {
|
||||||
|
Butt,
|
||||||
|
Round,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LineJoinStyle {
|
||||||
|
Miter,
|
||||||
|
Round,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ColorSpace {
|
||||||
|
Rgb,
|
||||||
|
Greyscale,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image data already compressed in this format (embedded unchanged).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ImageFilter {
|
||||||
|
/// JPEG
|
||||||
|
DCT,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image with 8 bits per component.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ImageXObject {
|
||||||
|
pub width: Px,
|
||||||
|
pub height: Px,
|
||||||
|
pub color_space: ColorSpace,
|
||||||
|
pub interpolate: bool,
|
||||||
|
/// Raw samples, or the compressed data for `image_filter`
|
||||||
|
pub image_data: Vec<u8>,
|
||||||
|
pub image_filter: Option<ImageFilter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font embedded with [`PdfDocumentReference::add_external_font`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct IndirectFontRef {
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IndirectFontRef {
|
||||||
|
fn resource_name(&self) -> String {
|
||||||
|
format!("F{}", self.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct PdfPageIndex(pub usize);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct PdfLayerIndex(pub usize);
|
||||||
|
|
||||||
|
struct LayerData {
|
||||||
|
name: String,
|
||||||
|
operations: Vec<Operation>,
|
||||||
|
/// Font of the current text section (to record the glyphs it uses)
|
||||||
|
current_font: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PageData {
|
||||||
|
width: Pt,
|
||||||
|
height: Pt,
|
||||||
|
layers: Vec<LayerData>,
|
||||||
|
images: Vec<ImageXObject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FontData {
|
||||||
|
face: ab_glyph::FontRef<'static>,
|
||||||
|
bytes: &'static [u8],
|
||||||
|
used_glyphs: BTreeSet<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DocumentData {
|
||||||
|
title: String,
|
||||||
|
pages: Vec<PageData>,
|
||||||
|
fonts: Vec<FontData>,
|
||||||
|
bookmarks: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point like printpdf's `PdfDocument::new`.
|
||||||
|
pub struct PdfDocument;
|
||||||
|
|
||||||
|
impl PdfDocument {
|
||||||
|
/// New document with a first page of the given size and its first layer.
|
||||||
|
#[allow(clippy::new_ret_no_self)]
|
||||||
|
pub fn new(
|
||||||
|
title: &str,
|
||||||
|
width: Mm,
|
||||||
|
height: Mm,
|
||||||
|
layer: &str,
|
||||||
|
) -> (PdfDocumentReference, PdfPageIndex, PdfLayerIndex) {
|
||||||
|
let doc = PdfDocumentReference(Rc::new(RefCell::new(DocumentData {
|
||||||
|
title: title.to_string(),
|
||||||
|
pages: Vec::new(),
|
||||||
|
fonts: Vec::new(),
|
||||||
|
bookmarks: Vec::new(),
|
||||||
|
})));
|
||||||
|
let (page, layer) = doc.add_page(width, height, layer);
|
||||||
|
(doc, page, layer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document being built; cheap to clone, all clones share it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PdfDocumentReference(Rc<RefCell<DocumentData>>);
|
||||||
|
|
||||||
|
impl PdfDocumentReference {
|
||||||
|
/// Appends a page with its first layer.
|
||||||
|
pub fn add_page(&self, width: Mm, height: Mm, layer: &str) -> (PdfPageIndex, PdfLayerIndex) {
|
||||||
|
let mut doc = self.0.borrow_mut();
|
||||||
|
doc.pages.push(PageData {
|
||||||
|
width: width.into(),
|
||||||
|
height: height.into(),
|
||||||
|
layers: vec![LayerData {
|
||||||
|
name: layer.to_string(),
|
||||||
|
operations: Vec::new(),
|
||||||
|
current_font: None,
|
||||||
|
}],
|
||||||
|
images: Vec::new(),
|
||||||
|
});
|
||||||
|
(PdfPageIndex(doc.pages.len() - 1), PdfLayerIndex(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookmark (outline entry) pointing at a page.
|
||||||
|
pub fn add_bookmark(&self, title: String, page: PdfPageIndex) {
|
||||||
|
self.0.borrow_mut().bookmarks.push((title, page.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_page(&self, page: PdfPageIndex) -> PdfPageReference {
|
||||||
|
PdfPageReference {
|
||||||
|
doc: self.clone(),
|
||||||
|
page: page.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embeds a TrueType/OpenType font with TrueType outlines (Type0 font, Identity-H).
|
||||||
|
pub fn add_external_font(
|
||||||
|
&self,
|
||||||
|
bytes: &'static [u8],
|
||||||
|
) -> Result<IndirectFontRef, Box<dyn Error>> {
|
||||||
|
let face = ab_glyph::FontRef::try_from_slice(bytes)?;
|
||||||
|
let mut doc = self.0.borrow_mut();
|
||||||
|
doc.fonts.push(FontData {
|
||||||
|
face,
|
||||||
|
bytes,
|
||||||
|
used_glyphs: BTreeSet::new(),
|
||||||
|
});
|
||||||
|
Ok(IndirectFontRef {
|
||||||
|
index: doc.fonts.len() - 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the document.
|
||||||
|
pub fn save_to_bytes(self) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
|
let data = self.0.borrow();
|
||||||
|
let mut doc = Document::with_version("1.5");
|
||||||
|
let pages_id = doc.new_object_id();
|
||||||
|
|
||||||
|
// Fonts: shared by all pages
|
||||||
|
let mut font_resources = Dictionary::new();
|
||||||
|
for (index, font) in data.fonts.iter().enumerate() {
|
||||||
|
let id = write_font(&mut doc, font, index)?;
|
||||||
|
font_resources.set(format!("F{index}"), Object::Reference(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One optional content group per layer name, in the order of appearance
|
||||||
|
let mut layer_names: Vec<&str> = Vec::new();
|
||||||
|
for layer in data.pages.iter().flat_map(|p| &p.layers) {
|
||||||
|
if !layer_names.contains(&layer.name.as_str()) {
|
||||||
|
layer_names.push(&layer.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ocgs: Vec<ObjectId> = layer_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| {
|
||||||
|
let mut ocg = Dictionary::new();
|
||||||
|
ocg.set("Type", Object::Name(b"OCG".to_vec()));
|
||||||
|
ocg.set("Name", text_string(name));
|
||||||
|
doc.add_object(ocg)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut page_ids = Vec::new();
|
||||||
|
for page in &data.pages {
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
let mut properties = Dictionary::new();
|
||||||
|
for layer in &page.layers {
|
||||||
|
let index = layer_names
|
||||||
|
.iter()
|
||||||
|
.position(|n| *n == layer.name)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let tag = format!("MC{index}");
|
||||||
|
properties.set(tag.clone(), Object::Reference(ocgs[index]));
|
||||||
|
operations.push(Operation::new(
|
||||||
|
"BDC",
|
||||||
|
vec![Object::Name(b"OC".to_vec()), Object::Name(tag.into_bytes())],
|
||||||
|
));
|
||||||
|
operations.push(Operation::new("q", vec![]));
|
||||||
|
operations.extend(layer.operations.iter().cloned());
|
||||||
|
operations.push(Operation::new("Q", vec![]));
|
||||||
|
operations.push(Operation::new("EMC", vec![]));
|
||||||
|
}
|
||||||
|
let mut contents = Stream::new(Dictionary::new(), Content { operations }.encode()?);
|
||||||
|
contents.compress()?;
|
||||||
|
let contents_id = doc.add_object(contents);
|
||||||
|
|
||||||
|
let mut xobjects = Dictionary::new();
|
||||||
|
for (i, image) in page.images.iter().enumerate() {
|
||||||
|
let id = doc.add_object(image_stream(image)?);
|
||||||
|
xobjects.set(format!("X{i}"), Object::Reference(id));
|
||||||
|
}
|
||||||
|
let mut resources = Dictionary::new();
|
||||||
|
resources.set("Font", Object::Dictionary(font_resources.clone()));
|
||||||
|
resources.set("XObject", Object::Dictionary(xobjects));
|
||||||
|
resources.set("Properties", Object::Dictionary(properties));
|
||||||
|
let resources_id = doc.add_object(resources);
|
||||||
|
|
||||||
|
let mut dict = Dictionary::new();
|
||||||
|
dict.set("Type", Object::Name(b"Page".to_vec()));
|
||||||
|
dict.set("Parent", Object::Reference(pages_id));
|
||||||
|
dict.set(
|
||||||
|
"MediaBox",
|
||||||
|
Object::Array(vec![
|
||||||
|
0.into(),
|
||||||
|
0.into(),
|
||||||
|
page.width.0.into(),
|
||||||
|
page.height.0.into(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
dict.set("Contents", Object::Reference(contents_id));
|
||||||
|
dict.set("Resources", Object::Reference(resources_id));
|
||||||
|
page_ids.push(doc.add_object(dict));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pages = Dictionary::new();
|
||||||
|
pages.set("Type", Object::Name(b"Pages".to_vec()));
|
||||||
|
pages.set(
|
||||||
|
"Kids",
|
||||||
|
Object::Array(page_ids.iter().map(|id| Object::Reference(*id)).collect()),
|
||||||
|
);
|
||||||
|
pages.set("Count", Object::Integer(page_ids.len() as i64));
|
||||||
|
doc.objects.insert(pages_id, Object::Dictionary(pages));
|
||||||
|
|
||||||
|
let mut catalog = Dictionary::new();
|
||||||
|
catalog.set("Type", Object::Name(b"Catalog".to_vec()));
|
||||||
|
catalog.set("Pages", Object::Reference(pages_id));
|
||||||
|
let ocg_refs: Vec<Object> = ocgs.iter().map(|id| Object::Reference(*id)).collect();
|
||||||
|
let mut default_config = Dictionary::new();
|
||||||
|
default_config.set("Order", Object::Array(ocg_refs.clone()));
|
||||||
|
let mut oc_properties = Dictionary::new();
|
||||||
|
oc_properties.set("OCGs", Object::Array(ocg_refs));
|
||||||
|
oc_properties.set("D", Object::Dictionary(default_config));
|
||||||
|
catalog.set("OCProperties", Object::Dictionary(oc_properties));
|
||||||
|
if !data.bookmarks.is_empty() {
|
||||||
|
let outlines = write_outlines(&mut doc, &data.bookmarks, &page_ids);
|
||||||
|
catalog.set("Outlines", Object::Reference(outlines));
|
||||||
|
catalog.set("PageMode", Object::Name(b"UseOutlines".to_vec()));
|
||||||
|
}
|
||||||
|
let catalog_id = doc.add_object(catalog);
|
||||||
|
|
||||||
|
let mut info = Dictionary::new();
|
||||||
|
info.set("Title", text_string(&data.title));
|
||||||
|
info.set("Producer", text_string("ink-notes"));
|
||||||
|
let info_id = doc.add_object(info);
|
||||||
|
|
||||||
|
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||||
|
doc.trailer.set("Info", Object::Reference(info_id));
|
||||||
|
let mut out = Vec::new();
|
||||||
|
doc.save_to(&mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PDF text string: literal for ASCII, otherwise UTF-16BE with byte order mark.
|
||||||
|
pub fn text_string(text: &str) -> Object {
|
||||||
|
if text.is_ascii() {
|
||||||
|
return Object::String(text.as_bytes().to_vec(), StringFormat::Literal);
|
||||||
|
}
|
||||||
|
let mut bytes = vec![0xFE, 0xFF];
|
||||||
|
for unit in text.encode_utf16() {
|
||||||
|
bytes.extend_from_slice(&unit.to_be_bytes());
|
||||||
|
}
|
||||||
|
Object::String(bytes, StringFormat::Hexadecimal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline tree with one entry per bookmark; returns the outline root.
|
||||||
|
fn write_outlines(
|
||||||
|
doc: &mut Document,
|
||||||
|
bookmarks: &[(String, usize)],
|
||||||
|
pages: &[ObjectId],
|
||||||
|
) -> ObjectId {
|
||||||
|
let root_id = doc.new_object_id();
|
||||||
|
let ids: Vec<ObjectId> = bookmarks.iter().map(|_| doc.new_object_id()).collect();
|
||||||
|
for (i, (title, page)) in bookmarks.iter().enumerate() {
|
||||||
|
let mut item = Dictionary::new();
|
||||||
|
item.set("Title", text_string(title));
|
||||||
|
item.set("Parent", Object::Reference(root_id));
|
||||||
|
if i > 0 {
|
||||||
|
item.set("Prev", Object::Reference(ids[i - 1]));
|
||||||
|
}
|
||||||
|
if let Some(next) = ids.get(i + 1) {
|
||||||
|
item.set("Next", Object::Reference(*next));
|
||||||
|
}
|
||||||
|
if let Some(page_id) = pages.get(*page) {
|
||||||
|
item.set(
|
||||||
|
"Dest",
|
||||||
|
Object::Array(vec![
|
||||||
|
Object::Reference(*page_id),
|
||||||
|
Object::Name(b"Fit".to_vec()),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
doc.objects.insert(ids[i], Object::Dictionary(item));
|
||||||
|
}
|
||||||
|
let mut root = Dictionary::new();
|
||||||
|
root.set("Type", Object::Name(b"Outlines".to_vec()));
|
||||||
|
root.set("First", Object::Reference(ids[0]));
|
||||||
|
root.set("Last", Object::Reference(ids[ids.len() - 1]));
|
||||||
|
root.set("Count", Object::Integer(ids.len() as i64));
|
||||||
|
doc.objects.insert(root_id, Object::Dictionary(root));
|
||||||
|
root_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image XObject stream.
|
||||||
|
fn image_stream(image: &ImageXObject) -> Result<Stream, Box<dyn Error>> {
|
||||||
|
let mut dict = Dictionary::new();
|
||||||
|
dict.set("Type", Object::Name(b"XObject".to_vec()));
|
||||||
|
dict.set("Subtype", Object::Name(b"Image".to_vec()));
|
||||||
|
dict.set("Width", Object::Integer(image.width.0 as i64));
|
||||||
|
dict.set("Height", Object::Integer(image.height.0 as i64));
|
||||||
|
let color_space: &[u8] = match image.color_space {
|
||||||
|
ColorSpace::Rgb => b"DeviceRGB",
|
||||||
|
ColorSpace::Greyscale => b"DeviceGray",
|
||||||
|
};
|
||||||
|
dict.set("ColorSpace", Object::Name(color_space.to_vec()));
|
||||||
|
dict.set("BitsPerComponent", Object::Integer(8));
|
||||||
|
dict.set("Interpolate", Object::Boolean(image.interpolate));
|
||||||
|
match image.image_filter {
|
||||||
|
Some(ImageFilter::DCT) => {
|
||||||
|
dict.set("Filter", Object::Name(b"DCTDecode".to_vec()));
|
||||||
|
Ok(Stream::new(dict, image.image_data.clone()).with_compression(false))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let mut stream = Stream::new(dict, image.image_data.clone());
|
||||||
|
stream.compress()?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Embeds a font as Type0 font with a CIDFontType2 descendant (glyph ids as character codes),
|
||||||
|
/// widths and a ToUnicode map of the glyphs used, so that the text can be searched and copied.
|
||||||
|
fn write_font(
|
||||||
|
doc: &mut Document,
|
||||||
|
font: &FontData,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<ObjectId, Box<dyn Error>> {
|
||||||
|
let face = &font.face;
|
||||||
|
let units_per_em = face.units_per_em().unwrap_or(1000.0);
|
||||||
|
let scale = 1000.0 / units_per_em;
|
||||||
|
let name = format!("InkNotesFont{index}");
|
||||||
|
|
||||||
|
let mut file = Stream::new(
|
||||||
|
Dictionary::from_iter(vec![("Length1", Object::Integer(font.bytes.len() as i64))]),
|
||||||
|
font.bytes.to_vec(),
|
||||||
|
);
|
||||||
|
file.compress()?;
|
||||||
|
let file_id = doc.add_object(file);
|
||||||
|
|
||||||
|
let ascent = (face.ascent_unscaled() * scale).round() as i64;
|
||||||
|
let descent = (face.descent_unscaled() * scale).round() as i64;
|
||||||
|
let mut descriptor = Dictionary::new();
|
||||||
|
descriptor.set("Type", Object::Name(b"FontDescriptor".to_vec()));
|
||||||
|
descriptor.set("FontName", Object::Name(name.clone().into_bytes()));
|
||||||
|
descriptor.set("Flags", Object::Integer(32));
|
||||||
|
descriptor.set(
|
||||||
|
"FontBBox",
|
||||||
|
Object::Array(vec![0.into(), descent.into(), 1000.into(), ascent.into()]),
|
||||||
|
);
|
||||||
|
descriptor.set("ItalicAngle", Object::Integer(0));
|
||||||
|
descriptor.set("Ascent", Object::Integer(ascent));
|
||||||
|
descriptor.set("Descent", Object::Integer(descent));
|
||||||
|
descriptor.set("CapHeight", Object::Integer(ascent));
|
||||||
|
descriptor.set("StemV", Object::Integer(80));
|
||||||
|
descriptor.set("FontFile2", Object::Reference(file_id));
|
||||||
|
let descriptor_id = doc.add_object(descriptor);
|
||||||
|
|
||||||
|
// Widths of the glyphs used: gid [width]
|
||||||
|
let mut widths = Vec::new();
|
||||||
|
for &gid in &font.used_glyphs {
|
||||||
|
let width = face.h_advance_unscaled(ab_glyph::GlyphId(gid)) * scale;
|
||||||
|
widths.push(Object::Integer(gid as i64));
|
||||||
|
widths.push(Object::Array(vec![Object::Integer(width.round() as i64)]));
|
||||||
|
}
|
||||||
|
let mut system_info = Dictionary::new();
|
||||||
|
system_info.set("Registry", Object::string_literal("Adobe"));
|
||||||
|
system_info.set("Ordering", Object::string_literal("Identity"));
|
||||||
|
system_info.set("Supplement", Object::Integer(0));
|
||||||
|
let mut descendant = Dictionary::new();
|
||||||
|
descendant.set("Type", Object::Name(b"Font".to_vec()));
|
||||||
|
descendant.set("Subtype", Object::Name(b"CIDFontType2".to_vec()));
|
||||||
|
descendant.set("BaseFont", Object::Name(name.clone().into_bytes()));
|
||||||
|
descendant.set("CIDSystemInfo", Object::Dictionary(system_info));
|
||||||
|
descendant.set("FontDescriptor", Object::Reference(descriptor_id));
|
||||||
|
descendant.set("CIDToGIDMap", Object::Name(b"Identity".to_vec()));
|
||||||
|
descendant.set("DW", Object::Integer(1000));
|
||||||
|
descendant.set("W", Object::Array(widths));
|
||||||
|
let descendant_id = doc.add_object(descendant);
|
||||||
|
|
||||||
|
let mut to_unicode = Stream::new(Dictionary::new(), to_unicode_cmap(font).into_bytes());
|
||||||
|
to_unicode.compress()?;
|
||||||
|
let to_unicode_id = doc.add_object(to_unicode);
|
||||||
|
|
||||||
|
let mut dict = Dictionary::new();
|
||||||
|
dict.set("Type", Object::Name(b"Font".to_vec()));
|
||||||
|
dict.set("Subtype", Object::Name(b"Type0".to_vec()));
|
||||||
|
dict.set("BaseFont", Object::Name(name.into_bytes()));
|
||||||
|
dict.set("Encoding", Object::Name(b"Identity-H".to_vec()));
|
||||||
|
dict.set(
|
||||||
|
"DescendantFonts",
|
||||||
|
Object::Array(vec![Object::Reference(descendant_id)]),
|
||||||
|
);
|
||||||
|
dict.set("ToUnicode", Object::Reference(to_unicode_id));
|
||||||
|
Ok(doc.add_object(dict))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CMap from the glyph ids used to their characters (UTF-16BE).
|
||||||
|
fn to_unicode_cmap(font: &FontData) -> String {
|
||||||
|
let mut chars: BTreeMap<u16, char> = BTreeMap::new();
|
||||||
|
for (gid, c) in font.face.codepoint_ids() {
|
||||||
|
if font.used_glyphs.contains(&gid.0) {
|
||||||
|
// Prefer the lowest code point (e.g. a letter over a compatibility form)
|
||||||
|
let entry = chars.entry(gid.0).or_insert(c);
|
||||||
|
if c < *entry {
|
||||||
|
*entry = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cmap = String::from(
|
||||||
|
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
|
||||||
|
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
|
||||||
|
/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
|
||||||
|
1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
|
||||||
|
);
|
||||||
|
let entries: Vec<(u16, char)> = chars.into_iter().collect();
|
||||||
|
for block in entries.chunks(100) {
|
||||||
|
cmap.push_str(&format!("{} beginbfchar\n", block.len()));
|
||||||
|
for (gid, c) in block {
|
||||||
|
let mut units = [0u16; 2];
|
||||||
|
let hex: String = c
|
||||||
|
.encode_utf16(&mut units)
|
||||||
|
.iter()
|
||||||
|
.map(|u| format!("{u:04X}"))
|
||||||
|
.collect();
|
||||||
|
cmap.push_str(&format!("<{gid:04X}> <{hex}>\n"));
|
||||||
|
}
|
||||||
|
cmap.push_str("endbfchar\n");
|
||||||
|
}
|
||||||
|
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||||
|
cmap
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PdfPageReference {
|
||||||
|
doc: PdfDocumentReference,
|
||||||
|
page: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfPageReference {
|
||||||
|
pub fn get_layer(&self, layer: PdfLayerIndex) -> PdfLayerReference {
|
||||||
|
PdfLayerReference {
|
||||||
|
doc: self.doc.clone(),
|
||||||
|
page: self.page,
|
||||||
|
layer: layer.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a layer drawn above the existing ones.
|
||||||
|
pub fn add_layer(&self, name: &str) -> PdfLayerReference {
|
||||||
|
let mut doc = self.doc.0.borrow_mut();
|
||||||
|
let layers = &mut doc.pages[self.page].layers;
|
||||||
|
layers.push(LayerData {
|
||||||
|
name: name.to_string(),
|
||||||
|
operations: Vec::new(),
|
||||||
|
current_font: None,
|
||||||
|
});
|
||||||
|
PdfLayerReference {
|
||||||
|
doc: self.doc.clone(),
|
||||||
|
page: self.page,
|
||||||
|
layer: layers.len() - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layer of a page; all drawing goes through it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PdfLayerReference {
|
||||||
|
doc: PdfDocumentReference,
|
||||||
|
page: usize,
|
||||||
|
layer: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfLayerReference {
|
||||||
|
fn with_layer<T>(&self, f: impl FnOnce(&mut LayerData) -> T) -> T {
|
||||||
|
let mut doc = self.doc.0.borrow_mut();
|
||||||
|
f(&mut doc.pages[self.page].layers[self.layer])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, operator: &str, operands: Vec<Object>) {
|
||||||
|
self.add_operation(Operation::new(operator, operands));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_operation(&self, operation: Operation) {
|
||||||
|
self.with_layer(|layer| layer.operations.push(operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_graphics_state(&self) {
|
||||||
|
self.push("q", vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore_graphics_state(&self) {
|
||||||
|
self.push("Q", vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_ctm(&self, matrix: CurTransMat) {
|
||||||
|
let m = match matrix {
|
||||||
|
CurTransMat::Translate(x, y) => [1.0, 0.0, 0.0, 1.0, x.0, y.0],
|
||||||
|
CurTransMat::Raw(m) => m,
|
||||||
|
};
|
||||||
|
self.push("cm", m.iter().map(|&v| v.into()).collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_fill_color(&self, color: Color) {
|
||||||
|
let Color::Rgb(c) = color;
|
||||||
|
self.push("rg", vec![c.r.into(), c.g.into(), c.b.into()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_outline_color(&self, color: Color) {
|
||||||
|
let Color::Rgb(c) = color;
|
||||||
|
self.push("RG", vec![c.r.into(), c.g.into(), c.b.into()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_outline_thickness(&self, thickness: f32) {
|
||||||
|
self.push("w", vec![thickness.into()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_line_cap_style(&self, style: LineCapStyle) {
|
||||||
|
let value = match style {
|
||||||
|
LineCapStyle::Butt => 0,
|
||||||
|
LineCapStyle::Round => 1,
|
||||||
|
};
|
||||||
|
self.push("J", vec![Object::Integer(value)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_line_join_style(&self, style: LineJoinStyle) {
|
||||||
|
let value = match style {
|
||||||
|
LineJoinStyle::Miter => 0,
|
||||||
|
LineJoinStyle::Round => 1,
|
||||||
|
};
|
||||||
|
self.push("j", vec![Object::Integer(value)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_line(&self, line: Line) {
|
||||||
|
if line.points.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut operations = path_operations(&line.points);
|
||||||
|
operations.push(Operation::new(
|
||||||
|
if line.is_closed { "s" } else { "S" },
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
self.with_layer(|layer| layer.operations.extend(operations));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_polygon(&self, polygon: Polygon) {
|
||||||
|
let mut operations: Vec<Operation> = polygon
|
||||||
|
.rings
|
||||||
|
.iter()
|
||||||
|
.filter(|ring| !ring.is_empty())
|
||||||
|
.flat_map(|ring| path_operations(ring))
|
||||||
|
.collect();
|
||||||
|
if operations.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operations.push(Operation::new(
|
||||||
|
match polygon.mode {
|
||||||
|
PaintMode::Fill => "f",
|
||||||
|
PaintMode::Stroke => "s",
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
self.with_layer(|layer| layer.operations.extend(operations));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws `image` into the unit square of the current coordinate system.
|
||||||
|
pub fn add_image(&self, image: ImageXObject) {
|
||||||
|
let mut doc = self.doc.0.borrow_mut();
|
||||||
|
let page = &mut doc.pages[self.page];
|
||||||
|
page.images.push(image);
|
||||||
|
let name = format!("X{}", page.images.len() - 1);
|
||||||
|
page.layers[self.layer]
|
||||||
|
.operations
|
||||||
|
.push(Operation::new("Do", vec![Object::Name(name.into_bytes())]));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn begin_text_section(&self) {
|
||||||
|
self.push("BT", vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end_text_section(&self) {
|
||||||
|
self.push("ET", vec![]);
|
||||||
|
self.with_layer(|layer| layer.current_font = None);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_font(&self, font: &IndirectFontRef, size: f32) {
|
||||||
|
self.push(
|
||||||
|
"Tf",
|
||||||
|
vec![Object::Name(font.resource_name().into_bytes()), size.into()],
|
||||||
|
);
|
||||||
|
self.with_layer(|layer| layer.current_font = Some(font.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_text_matrix(&self, matrix: TextMatrix) {
|
||||||
|
let TextMatrix::Translate(x, y) = matrix;
|
||||||
|
self.push(
|
||||||
|
"Tm",
|
||||||
|
vec![
|
||||||
|
1.into(),
|
||||||
|
0.into(),
|
||||||
|
0.into(),
|
||||||
|
1.into(),
|
||||||
|
x.0.into(),
|
||||||
|
y.0.into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows glyphs of the current font; each comes with a shift before it in 1/1000 em
|
||||||
|
/// (positive moves left, as in `TJ`).
|
||||||
|
pub fn write_positioned_codepoints(&self, glyphs: impl IntoIterator<Item = (i64, u16)>) {
|
||||||
|
let mut list = Vec::new();
|
||||||
|
let mut used = Vec::new();
|
||||||
|
for (shift, gid) in glyphs {
|
||||||
|
if shift != 0 {
|
||||||
|
list.push(Object::Integer(shift));
|
||||||
|
}
|
||||||
|
list.push(Object::String(
|
||||||
|
gid.to_be_bytes().to_vec(),
|
||||||
|
StringFormat::Hexadecimal,
|
||||||
|
));
|
||||||
|
used.push(gid);
|
||||||
|
}
|
||||||
|
let mut doc = self.doc.0.borrow_mut();
|
||||||
|
let layer = &mut doc.pages[self.page].layers[self.layer];
|
||||||
|
layer
|
||||||
|
.operations
|
||||||
|
.push(Operation::new("TJ", vec![Object::Array(list)]));
|
||||||
|
if let Some(font) = layer.current_font {
|
||||||
|
doc.fonts[font].used_glyphs.extend(used);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `m`, `l` and `c` operations for a point list (see [`Line`] for the Bézier marking).
|
||||||
|
fn path_operations(points: &[(Point, bool)]) -> Vec<Operation> {
|
||||||
|
let xy = |p: &Point| -> [Object; 2] { [p.x.0.into(), p.y.0.into()] };
|
||||||
|
let mut operations = vec![Operation::new("m", xy(&points[0].0).to_vec())];
|
||||||
|
let mut current = 1;
|
||||||
|
while current < points.len() {
|
||||||
|
let (previous, point) = (&points[current - 1], &points[current]);
|
||||||
|
if previous.1 && point.1 {
|
||||||
|
if let (Some(c2), Some(end)) = (points.get(current + 1), points.get(current + 2)) {
|
||||||
|
let operands = [xy(&point.0), xy(&c2.0), xy(&end.0)].concat();
|
||||||
|
operations.push(Operation::new("c", operands));
|
||||||
|
current += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
operations.push(Operation::new("l", xy(&point.0).to_vec()));
|
||||||
|
current += 1;
|
||||||
|
}
|
||||||
|
operations
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn line(points: &[(f32, f32, bool)]) -> Vec<(Point, bool)> {
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.map(|&(x, y, b)| (Point::new(Mm(x), Mm(y)), b))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_path_operations_lines_and_curves() {
|
||||||
|
let ops = path_operations(&line(&[
|
||||||
|
(0.0, 0.0, true),
|
||||||
|
(1.0, 0.0, true),
|
||||||
|
(2.0, 0.0, false),
|
||||||
|
(3.0, 0.0, false),
|
||||||
|
(4.0, 0.0, false),
|
||||||
|
]));
|
||||||
|
let names: Vec<&str> = ops.iter().map(|o| o.operator.as_str()).collect();
|
||||||
|
assert_eq!(names, ["m", "c", "l"]);
|
||||||
|
assert_eq!(ops[1].operands.len(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_document_with_text_image_and_bookmarks() {
|
||||||
|
let (doc, page, layer) = PdfDocument::new("Test", Mm(210.0), Mm(297.0), "Background");
|
||||||
|
let font = doc
|
||||||
|
.add_external_font(crate::text::TextFont::Sans.bytes())
|
||||||
|
.unwrap();
|
||||||
|
doc.add_bookmark("Übung – 1".to_string(), page);
|
||||||
|
let background = doc.get_page(page).get_layer(layer);
|
||||||
|
background.add_polygon(Polygon {
|
||||||
|
rings: vec![line(&[
|
||||||
|
(0.0, 0.0, false),
|
||||||
|
(10.0, 0.0, false),
|
||||||
|
(10.0, 10.0, false),
|
||||||
|
])],
|
||||||
|
mode: PaintMode::Fill,
|
||||||
|
winding_order: WindingOrder::NonZero,
|
||||||
|
});
|
||||||
|
let text = doc.get_page(page).add_layer("Text");
|
||||||
|
let face = crate::text::TextFont::Sans.face();
|
||||||
|
let gids: Vec<u16> = "Hä".chars().map(|c| face.glyph_id(c).0).collect();
|
||||||
|
text.begin_text_section();
|
||||||
|
text.set_font(&font, 12.0);
|
||||||
|
text.write_positioned_codepoints(gids.iter().map(|&g| (0, g)));
|
||||||
|
text.end_text_section();
|
||||||
|
text.add_image(ImageXObject {
|
||||||
|
width: Px(1),
|
||||||
|
height: Px(1),
|
||||||
|
color_space: ColorSpace::Rgb,
|
||||||
|
interpolate: true,
|
||||||
|
image_data: vec![255, 0, 0],
|
||||||
|
image_filter: None,
|
||||||
|
});
|
||||||
|
let bytes = doc.save_to_bytes().unwrap();
|
||||||
|
|
||||||
|
let pdf = Document::load_mem(&bytes).unwrap();
|
||||||
|
assert_eq!(pdf.get_pages().len(), 1);
|
||||||
|
let page_id = *pdf.get_pages().values().next().unwrap();
|
||||||
|
let content = pdf.get_page_content(page_id);
|
||||||
|
let ops = Content::decode(&content).unwrap().operations;
|
||||||
|
let names: Vec<&str> = ops.iter().map(|o| o.operator.as_str()).collect();
|
||||||
|
for expected in ["BDC", "f", "BT", "Tf", "TJ", "ET", "Do", "EMC"] {
|
||||||
|
assert!(names.contains(&expected), "{expected} missing in {names:?}");
|
||||||
|
}
|
||||||
|
// The ToUnicode map knows both characters
|
||||||
|
let fonts = pdf.get_page_fonts(page_id).unwrap();
|
||||||
|
let font = fonts.values().next().unwrap();
|
||||||
|
let to_unicode = pdf
|
||||||
|
.get_object(font.get(b"ToUnicode").unwrap().as_reference().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.as_stream()
|
||||||
|
.unwrap()
|
||||||
|
.decompressed_content()
|
||||||
|
.unwrap();
|
||||||
|
let cmap = String::from_utf8(to_unicode).unwrap();
|
||||||
|
assert!(cmap.contains("<0048>"), "{cmap}");
|
||||||
|
assert!(cmap.contains("<00E4>"), "{cmap}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
//! Pen input independent of the platform (Issue #44).
|
||||||
|
//!
|
||||||
|
//! On the desktop the pen comes from octotablet (Wayland/X11 tablet protocols, Windows Ink),
|
||||||
|
//! on Android as touch samples with the tool type stylus or eraser, which the patched winit
|
||||||
|
//! hands over separately (`winit::platform::android::take_pen_samples`). Both are turned into
|
||||||
|
//! the same [`PenInput`] events, which the app handles in one place.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// What happened to the pen.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum PenEvent {
|
||||||
|
/// Pen comes into range (hovers above the screen)
|
||||||
|
In,
|
||||||
|
/// Pen left the range
|
||||||
|
Out,
|
||||||
|
/// Tip touches the screen (at the position of the last `Pose`)
|
||||||
|
Down,
|
||||||
|
/// Tip lifted
|
||||||
|
Up,
|
||||||
|
/// Position in logical pixels; pressure 0..=1 if known
|
||||||
|
Pose {
|
||||||
|
position: [f32; 2],
|
||||||
|
pressure: Option<f32>,
|
||||||
|
},
|
||||||
|
/// Pen button pressed or released; `key` identifies the button across sessions
|
||||||
|
Button { key: u64, pressed: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PenInput {
|
||||||
|
/// Identifies the pen (several pens can be in use on the desktop)
|
||||||
|
pub tool: u64,
|
||||||
|
/// Eraser end of the pen
|
||||||
|
pub is_eraser: bool,
|
||||||
|
pub event: PenEvent,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_of(value: impl std::hash::Hash) -> u64 {
|
||||||
|
use std::hash::Hasher;
|
||||||
|
// DefaultHasher::new() uses fixed keys: same input, same value in every session
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
value.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns octotablet's events into [`PenInput`]s, with each `Down` after the `Pose` of the
|
||||||
|
/// same frame (see [`crate::platform::down_after_pose`]).
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub fn from_octotablet<'a>(
|
||||||
|
events: impl IntoIterator<Item = octotablet::events::Event<'a>>,
|
||||||
|
) -> Vec<PenInput> {
|
||||||
|
use octotablet::events::{Event, ToolEvent};
|
||||||
|
|
||||||
|
let inputs = events.into_iter().filter_map(|event| {
|
||||||
|
let Event::Tool { tool, event } = event else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let event = match event {
|
||||||
|
ToolEvent::In { .. } => PenEvent::In,
|
||||||
|
ToolEvent::Out | ToolEvent::Removed => PenEvent::Out,
|
||||||
|
ToolEvent::Down => PenEvent::Down,
|
||||||
|
ToolEvent::Up => PenEvent::Up,
|
||||||
|
ToolEvent::Pose(pose) => PenEvent::Pose {
|
||||||
|
position: pose.position,
|
||||||
|
pressure: pose.pressure.get(),
|
||||||
|
},
|
||||||
|
ToolEvent::Button { button_id, pressed } => PenEvent::Button {
|
||||||
|
key: hash_of(button_id),
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(PenInput {
|
||||||
|
tool: hash_of(tool.id()),
|
||||||
|
is_eraser: tool.tool_type == Some(octotablet::tool::Type::Eraser),
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
crate::platform::down_after_pose(
|
||||||
|
inputs,
|
||||||
|
|i| i.event == PenEvent::Down,
|
||||||
|
|i| matches!(i.event, PenEvent::Pose { .. }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an Android pen sample reports (mirrors `winit::platform::android::PenAction`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SampleAction {
|
||||||
|
HoverEnter,
|
||||||
|
HoverMove,
|
||||||
|
HoverExit,
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One Android pen sample (mirrors `winit::platform::android::PenSample`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Sample {
|
||||||
|
pub action: SampleAction,
|
||||||
|
/// Physical pixels
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub pressure: f32,
|
||||||
|
pub eraser: bool,
|
||||||
|
/// `MotionEvent.getButtonState()`
|
||||||
|
pub buttons: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl From<winit::platform::android::PenSample> for Sample {
|
||||||
|
fn from(s: winit::platform::android::PenSample) -> Self {
|
||||||
|
use winit::platform::android::PenAction as A;
|
||||||
|
Self {
|
||||||
|
action: match s.action {
|
||||||
|
A::HoverEnter => SampleAction::HoverEnter,
|
||||||
|
A::HoverMove => SampleAction::HoverMove,
|
||||||
|
A::HoverExit => SampleAction::HoverExit,
|
||||||
|
A::Down => SampleAction::Down,
|
||||||
|
A::Move => SampleAction::Move,
|
||||||
|
A::Up => SampleAction::Up,
|
||||||
|
A::Cancel => SampleAction::Cancel,
|
||||||
|
},
|
||||||
|
x: s.x,
|
||||||
|
y: s.y,
|
||||||
|
pressure: s.pressure,
|
||||||
|
eraser: s.eraser,
|
||||||
|
buttons: s.buttons,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two pen buttons of Android's `MotionEvent` (`BUTTON_STYLUS_PRIMARY`/`SECONDARY`).
|
||||||
|
const ANDROID_BUTTONS: [(u32, &str); 2] = [
|
||||||
|
(0x20, "android-stylus-primary"),
|
||||||
|
(0x40, "android-stylus-secondary"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// How long the pen may be out of sight before it counts as gone. Android reports
|
||||||
|
/// `HoverExit` right before the tip touches down, and pens without hover report nothing
|
||||||
|
/// after lifting.
|
||||||
|
const ANDROID_OUT_DELAY: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
/// Turns Android pen samples into [`PenInput`]s.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct AndroidPen {
|
||||||
|
in_range: bool,
|
||||||
|
down: bool,
|
||||||
|
eraser: bool,
|
||||||
|
buttons: u32,
|
||||||
|
/// Since when the pen is neither hovering nor touching
|
||||||
|
gone_since: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidPen {
|
||||||
|
/// The single pen Android reports
|
||||||
|
const TOOL: u64 = 1;
|
||||||
|
|
||||||
|
fn push(&self, out: &mut Vec<PenInput>, event: PenEvent) {
|
||||||
|
out.push(PenInput {
|
||||||
|
tool: Self::TOOL,
|
||||||
|
is_eraser: self.eraser,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events for `samples` (in this order); `scale` converts physical to logical pixels.
|
||||||
|
pub fn convert(&mut self, samples: &[Sample], scale: f32, now: Instant) -> Vec<PenInput> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for sample in samples {
|
||||||
|
self.eraser = sample.eraser;
|
||||||
|
for (mask, name) in ANDROID_BUTTONS {
|
||||||
|
let pressed = sample.buttons & mask != 0;
|
||||||
|
if pressed != (self.buttons & mask != 0) {
|
||||||
|
self.push(
|
||||||
|
&mut out,
|
||||||
|
PenEvent::Button {
|
||||||
|
key: hash_of(name),
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.buttons = sample.buttons;
|
||||||
|
|
||||||
|
let position = [sample.x / scale, sample.y / scale];
|
||||||
|
let touching = Some(sample.pressure.clamp(0.0, 1.0));
|
||||||
|
match sample.action {
|
||||||
|
SampleAction::HoverEnter | SampleAction::HoverMove => {
|
||||||
|
self.gone_since = None;
|
||||||
|
self.enter(&mut out);
|
||||||
|
let pose = PenEvent::Pose {
|
||||||
|
position,
|
||||||
|
pressure: None,
|
||||||
|
};
|
||||||
|
self.push(&mut out, pose);
|
||||||
|
}
|
||||||
|
SampleAction::HoverExit => {
|
||||||
|
if !self.down {
|
||||||
|
self.gone_since.get_or_insert(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SampleAction::Down => {
|
||||||
|
self.gone_since = None;
|
||||||
|
self.enter(&mut out);
|
||||||
|
let pose = PenEvent::Pose {
|
||||||
|
position,
|
||||||
|
pressure: touching,
|
||||||
|
};
|
||||||
|
self.push(&mut out, pose);
|
||||||
|
if !self.down {
|
||||||
|
self.down = true;
|
||||||
|
self.push(&mut out, PenEvent::Down);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SampleAction::Move => {
|
||||||
|
self.gone_since = None;
|
||||||
|
self.enter(&mut out);
|
||||||
|
let pressure = if self.down { touching } else { None };
|
||||||
|
self.push(&mut out, PenEvent::Pose { position, pressure });
|
||||||
|
}
|
||||||
|
SampleAction::Up | SampleAction::Cancel => {
|
||||||
|
if self.down {
|
||||||
|
if sample.action == SampleAction::Up {
|
||||||
|
let pose = PenEvent::Pose {
|
||||||
|
position,
|
||||||
|
pressure: touching,
|
||||||
|
};
|
||||||
|
self.push(&mut out, pose);
|
||||||
|
}
|
||||||
|
self.down = false;
|
||||||
|
self.push(&mut out, PenEvent::Up);
|
||||||
|
}
|
||||||
|
self.gone_since = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.extend(self.poll(now));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter(&mut self, out: &mut Vec<PenInput>) {
|
||||||
|
if !self.in_range {
|
||||||
|
self.in_range = true;
|
||||||
|
self.push(out, PenEvent::In);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Out` once the pen has been gone for a moment; call regularly while it is in range.
|
||||||
|
pub fn poll(&mut self, now: Instant) -> Vec<PenInput> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let gone = self
|
||||||
|
.gone_since
|
||||||
|
.is_some_and(|since| now.duration_since(since) >= ANDROID_OUT_DELAY);
|
||||||
|
if gone && self.in_range && !self.down {
|
||||||
|
self.in_range = false;
|
||||||
|
self.gone_since = None;
|
||||||
|
if self.buttons != 0 {
|
||||||
|
for (mask, name) in ANDROID_BUTTONS {
|
||||||
|
if self.buttons & mask != 0 {
|
||||||
|
self.push(
|
||||||
|
&mut out,
|
||||||
|
PenEvent::Button {
|
||||||
|
key: hash_of(name),
|
||||||
|
pressed: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.buttons = 0;
|
||||||
|
}
|
||||||
|
self.push(&mut out, PenEvent::Out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample(action: SampleAction, x: f32, pressure: f32, buttons: u32) -> Sample {
|
||||||
|
Sample {
|
||||||
|
action,
|
||||||
|
x,
|
||||||
|
y: 10.0,
|
||||||
|
pressure,
|
||||||
|
eraser: false,
|
||||||
|
buttons,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kinds(inputs: &[PenInput]) -> Vec<&'static str> {
|
||||||
|
inputs
|
||||||
|
.iter()
|
||||||
|
.map(|i| match i.event {
|
||||||
|
PenEvent::In => "in",
|
||||||
|
PenEvent::Out => "out",
|
||||||
|
PenEvent::Down => "down",
|
||||||
|
PenEvent::Up => "up",
|
||||||
|
PenEvent::Pose { pressure: None, .. } => "hover",
|
||||||
|
PenEvent::Pose { .. } => "pose",
|
||||||
|
PenEvent::Button { pressed: true, .. } => "button-down",
|
||||||
|
PenEvent::Button { .. } => "button-up",
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_android_stroke_with_hover() {
|
||||||
|
use SampleAction::*;
|
||||||
|
let mut pen = AndroidPen::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let ms = |n| t + Duration::from_millis(n);
|
||||||
|
|
||||||
|
let out = pen.convert(&[sample(HoverEnter, 20.0, 0.0, 0)], 2.0, t);
|
||||||
|
assert_eq!(kinds(&out), ["in", "hover"]);
|
||||||
|
assert_eq!(
|
||||||
|
out[1].event,
|
||||||
|
PenEvent::Pose {
|
||||||
|
position: [10.0, 5.0],
|
||||||
|
pressure: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android ends the hover right before the tip touches: no Out in between
|
||||||
|
let out = pen.convert(&[sample(HoverExit, 20.0, 0.0, 0)], 2.0, ms(5));
|
||||||
|
assert!(out.is_empty());
|
||||||
|
let out = pen.convert(
|
||||||
|
&[sample(Down, 20.0, 0.4, 0), sample(Move, 30.0, 0.5, 0)],
|
||||||
|
2.0,
|
||||||
|
ms(10),
|
||||||
|
);
|
||||||
|
assert_eq!(kinds(&out), ["pose", "down", "pose"]);
|
||||||
|
assert!(pen.poll(ms(500)).is_empty(), "still touching");
|
||||||
|
|
||||||
|
let out = pen.convert(&[sample(Up, 30.0, 0.0, 0)], 2.0, ms(600));
|
||||||
|
assert_eq!(kinds(&out), ["pose", "up"]);
|
||||||
|
let out = pen.convert(&[sample(HoverEnter, 30.0, 0.0, 0)], 2.0, ms(610));
|
||||||
|
assert_eq!(kinds(&out), ["hover"]);
|
||||||
|
|
||||||
|
pen.convert(&[sample(HoverExit, 30.0, 0.0, 0)], 2.0, ms(700));
|
||||||
|
assert!(pen.poll(ms(750)).is_empty());
|
||||||
|
assert_eq!(kinds(&pen.poll(ms(900))), ["out"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_android_pen_without_hover_leaves_after_lifting() {
|
||||||
|
use SampleAction::*;
|
||||||
|
let mut pen = AndroidPen::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let out = pen.convert(&[sample(Down, 0.0, 0.7, 0)], 1.0, t);
|
||||||
|
assert_eq!(kinds(&out), ["in", "pose", "down"]);
|
||||||
|
pen.convert(&[sample(Up, 0.0, 0.0, 0)], 1.0, t);
|
||||||
|
assert_eq!(kinds(&pen.poll(t + Duration::from_millis(200))), ["out"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_android_buttons_and_cancel() {
|
||||||
|
use SampleAction::*;
|
||||||
|
let mut pen = AndroidPen::default();
|
||||||
|
let t = Instant::now();
|
||||||
|
let out = pen.convert(
|
||||||
|
&[
|
||||||
|
sample(HoverMove, 0.0, 0.0, 0x20),
|
||||||
|
sample(Down, 0.0, 0.5, 0x20),
|
||||||
|
sample(Cancel, 0.0, 0.0, 0x20),
|
||||||
|
],
|
||||||
|
1.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
kinds(&out),
|
||||||
|
["button-down", "in", "hover", "pose", "down", "up"]
|
||||||
|
);
|
||||||
|
// Pen gone with the button still held: released before Out
|
||||||
|
let out = pen.poll(t + Duration::from_millis(200));
|
||||||
|
assert_eq!(kinds(&out), ["button-up", "out"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
@@ -82,6 +82,192 @@ pub fn desktop_event_loop() -> Result<winit::event_loop::EventLoop<()>, winit::e
|
|||||||
builder.build()
|
builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Waits until the window can be drawn to. On Android the native window only exists after
|
||||||
|
/// `Event::Resumed` (Issue #42); on the desktop it is there right away.
|
||||||
|
pub fn wait_until_resumed(
|
||||||
|
#[allow(unused_variables)] event_loop: &mut winit::event_loop::EventLoop<()>,
|
||||||
|
) {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
use winit::event::Event;
|
||||||
|
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
|
||||||
|
let mut resumed = false;
|
||||||
|
while !resumed {
|
||||||
|
let status = event_loop.pump_events(None, |event, _| {
|
||||||
|
if matches!(event, Event::Resumed) {
|
||||||
|
resumed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let PumpStatus::Exit(_) = status {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the event loop until the app quits. On Android the loop was already started by
|
||||||
|
/// [`wait_until_resumed`], so it is driven with `pump_events` (`run` would refuse to start).
|
||||||
|
pub fn run_event_loop<F>(
|
||||||
|
#[allow(unused_mut)] mut event_loop: winit::event_loop::EventLoop<()>,
|
||||||
|
#[allow(unused_mut)] mut handler: F,
|
||||||
|
) -> Result<(), winit::error::EventLoopError>
|
||||||
|
where
|
||||||
|
F: FnMut(winit::event::Event<()>, &winit::event_loop::EventLoopWindowTarget<()>),
|
||||||
|
{
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
use winit::platform::pump_events::{EventLoopExtPumpEvents, PumpStatus};
|
||||||
|
loop {
|
||||||
|
if let PumpStatus::Exit(_) = event_loop.pump_events(None, &mut handler) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
event_loop.run(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Android activity, for calls winit doesn't offer (soft keyboard, Issue #42).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
static ANDROID_APP: std::sync::OnceLock<winit::platform::android::activity::AndroidApp> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Remembers the activity; called once by `android_main`.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn set_android_app(app: winit::platform::android::activity::AndroidApp) {
|
||||||
|
let _ = ANDROID_APP.set(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub fn android_app() -> Option<winit::platform::android::activity::AndroidApp> {
|
||||||
|
ANDROID_APP.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes an exported file available to the user. Desktop: it already is (the path is
|
||||||
|
/// returned). Android: copied to `Download/ink-notes` through the MediaStore (Android 10 and
|
||||||
|
/// newer), as other apps can't see the app's own folder; older versions keep it there.
|
||||||
|
pub fn publish_export(path: std::path::PathBuf) -> Result<std::path::PathBuf, String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
match android_files::publish_to_downloads(&path) {
|
||||||
|
Ok(location) => {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
return Ok(location);
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("export not copied to Downloads: {err}"),
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_files {
|
||||||
|
use jni::objects::{JObject, JValue};
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Folder below the shared storage the exports go to
|
||||||
|
const RELATIVE_PATH: &str = "Download/ink-notes";
|
||||||
|
|
||||||
|
fn mime_type(path: &Path) -> &'static str {
|
||||||
|
match path.extension().and_then(|e| e.to_str()) {
|
||||||
|
Some("pdf") => "application/pdf",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish_to_downloads(path: &Path) -> Result<PathBuf, String> {
|
||||||
|
let app = super::android_app().ok_or("no activity")?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or("invalid file name")?
|
||||||
|
.to_string();
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
// Safety: pointers to the VM and the activity (a global reference) that live as long
|
||||||
|
// as the app
|
||||||
|
let vm =
|
||||||
|
unsafe { jni::JavaVM::from_raw(app.vm_as_ptr().cast()) }.map_err(|e| e.to_string())?;
|
||||||
|
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||||
|
let activity = unsafe { JObject::from_raw(app.activity_as_ptr().cast()) };
|
||||||
|
let result = insert(&mut env, &activity, &name, mime_type(path), &bytes);
|
||||||
|
if env.exception_check().unwrap_or(false) {
|
||||||
|
let _ = env.exception_describe();
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
}
|
||||||
|
result.map_err(|e| e.to_string())?;
|
||||||
|
Ok(Path::new(RELATIVE_PATH).join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(
|
||||||
|
env: &mut JNIEnv,
|
||||||
|
activity: &JObject,
|
||||||
|
name: &str,
|
||||||
|
mime: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> jni::errors::Result<()> {
|
||||||
|
let sdk = env
|
||||||
|
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")?
|
||||||
|
.i()?;
|
||||||
|
if sdk < 29 {
|
||||||
|
return Err(jni::errors::Error::JniCall(jni::errors::JniError::Unknown));
|
||||||
|
}
|
||||||
|
let values = env.new_object("android/content/ContentValues", "()V", &[])?;
|
||||||
|
for (key, value) in [
|
||||||
|
("_display_name", name),
|
||||||
|
("mime_type", mime),
|
||||||
|
("relative_path", RELATIVE_PATH),
|
||||||
|
] {
|
||||||
|
let key = env.new_string(key)?;
|
||||||
|
let value = env.new_string(value)?;
|
||||||
|
env.call_method(
|
||||||
|
&values,
|
||||||
|
"put",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(&key), JValue::Object(&value)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let resolver = env
|
||||||
|
.call_method(
|
||||||
|
activity,
|
||||||
|
"getContentResolver",
|
||||||
|
"()Landroid/content/ContentResolver;",
|
||||||
|
&[],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let collection = env
|
||||||
|
.get_static_field(
|
||||||
|
"android/provider/MediaStore$Downloads",
|
||||||
|
"EXTERNAL_CONTENT_URI",
|
||||||
|
"Landroid/net/Uri;",
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let uri = env
|
||||||
|
.call_method(
|
||||||
|
&resolver,
|
||||||
|
"insert",
|
||||||
|
"(Landroid/net/Uri;Landroid/content/ContentValues;)Landroid/net/Uri;",
|
||||||
|
&[JValue::Object(&collection), JValue::Object(&values)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
if uri.is_null() {
|
||||||
|
return Err(jni::errors::Error::NullPtr("MediaStore insert"));
|
||||||
|
}
|
||||||
|
let stream = env
|
||||||
|
.call_method(
|
||||||
|
&resolver,
|
||||||
|
"openOutputStream",
|
||||||
|
"(Landroid/net/Uri;)Ljava/io/OutputStream;",
|
||||||
|
&[JValue::Object(&uri)],
|
||||||
|
)?
|
||||||
|
.l()?;
|
||||||
|
let array = env.byte_array_from_slice(bytes)?;
|
||||||
|
env.call_method(&stream, "write", "([B)V", &[JValue::Object(&array)])?;
|
||||||
|
env.call_method(&stream, "close", "()V", &[])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
|
/// Gives the window the app id (Wayland: app_id, X11: WM_CLASS), so that dock and task bar
|
||||||
/// show the app icon for it.
|
/// show the app icon for it.
|
||||||
pub fn with_app_id(
|
pub fn with_app_id(
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ pub struct UiSettings {
|
|||||||
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
||||||
/// (Issue #49)
|
/// (Issue #49)
|
||||||
pub storage_dir: Option<PathBuf>,
|
pub storage_dir: Option<PathBuf>,
|
||||||
|
/// Compute handwritten math after `=` (Issue #46)
|
||||||
|
pub math_assist: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiSettings {
|
impl Default for UiSettings {
|
||||||
@@ -253,6 +255,7 @@ impl Default for UiSettings {
|
|||||||
language: None,
|
language: None,
|
||||||
eraser_radius_mm: 3.0,
|
eraser_radius_mm: 3.0,
|
||||||
storage_dir: None,
|
storage_dir: None,
|
||||||
|
math_assist: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,6 +353,7 @@ mod tests {
|
|||||||
language: Some(Language::English),
|
language: Some(Language::English),
|
||||||
eraser_radius_mm: 6.0,
|
eraser_radius_mm: 6.0,
|
||||||
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
||||||
|
math_assist: true,
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ pub enum UiAction {
|
|||||||
UnlockCorruptedPage(u64),
|
UnlockCorruptedPage(u64),
|
||||||
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
/// Werkzeugleiste an die andere Fensterseite verlegen (wird gespeichert)
|
||||||
SetSidebarSide(SidebarSide),
|
SetSidebarSide(SidebarSide),
|
||||||
|
/// Compute handwritten math after `=` on or off (Issue #46)
|
||||||
|
SetMathAssist(bool),
|
||||||
/// Sprache der Oberfläche; `None` = Sprache des Systems
|
/// Sprache der Oberfläche; `None` = Sprache des Systems
|
||||||
SetLanguage(Option<crate::i18n::Language>),
|
SetLanguage(Option<crate::i18n::Language>),
|
||||||
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
/// Papierfarbe der Anzeige und Aufhellen dunkler Tinte (wird gespeichert)
|
||||||
@@ -413,6 +415,8 @@ pub struct UiState {
|
|||||||
pub update_modal_rect: Option<egui::Rect>,
|
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)
|
/// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom)
|
||||||
pub touch_draw_enabled: bool,
|
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)
|
/// 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)>,
|
pub last_deleted_notebook: Option<(std::path::PathBuf, String)>,
|
||||||
/// Stifte der Werkzeugleiste (Farbe und Stärke) und der gewählte davon; `pen_color` und
|
/// 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,
|
show_updater_config: false,
|
||||||
update_modal_rect: None,
|
update_modal_rect: None,
|
||||||
touch_draw_enabled: false,
|
touch_draw_enabled: false,
|
||||||
|
math: crate::math_assist::MathAssist::default(),
|
||||||
last_deleted_notebook: None,
|
last_deleted_notebook: None,
|
||||||
pen_presets: DEFAULT_PEN_PRESETS,
|
pen_presets: DEFAULT_PEN_PRESETS,
|
||||||
active_pen: 0,
|
active_pen: 0,
|
||||||
@@ -690,6 +695,11 @@ impl UiState {
|
|||||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||||
let dir = self.last_picker_dir.clone();
|
let dir = self.last_picker_dir.clone();
|
||||||
self.close_overlays();
|
self.close_overlays();
|
||||||
|
// Android: the app can't look into other apps' folders; the system dialog can
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if crate::android_bridge::pick_file(kind) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
|
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1647,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
|
/// 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.
|
/// 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) {
|
fn text_edit_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||||
let adapt = state.adapt_ink_colors && state.paper_theme.is_dark();
|
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 {
|
let (Some(view), Some(edit)) = (state.page_view, state.text_edit.as_mut()) else {
|
||||||
@@ -3145,6 +3206,7 @@ pub fn render_ui(
|
|||||||
space_preview(ctx, state, notebook);
|
space_preview(ctx, state, notebook);
|
||||||
ruler_overlay(ctx, state);
|
ruler_overlay(ctx, state);
|
||||||
text_edit_overlay(ctx, state);
|
text_edit_overlay(ctx, state);
|
||||||
|
math_suggestion(ctx, state, notebook);
|
||||||
context_menu(ctx, state, &mut actions);
|
context_menu(ctx, state, &mut actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
@@ -4378,6 +4440,15 @@ pub fn render_ui(
|
|||||||
.on_hover_text(
|
.on_hover_text(
|
||||||
t!("Aus: ein Finger verschiebt, zwei Finger zoomen (empfohlen mit Stift).\nAn: ein Finger zeichnet."),
|
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) {
|
if let Some(curve) = pressure_curve_editor(ui, state) {
|
||||||
actions.push(UiAction::SavePressureCurve(curve));
|
actions.push(UiAction::SavePressureCurve(curve));
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+35
@@ -77,6 +77,41 @@ impl<T> EventLoopBuilderExtAndroid for EventLoopBuilder<T> {
|
|||||||
/// depending on the `android_activity` crate, and instead consume the API that
|
/// depending on the `android_activity` crate, and instead consume the API that
|
||||||
/// is re-exported by Winit.
|
/// is re-exported by Winit.
|
||||||
///
|
///
|
||||||
|
/// ink-notes patch: what a pen sample reports (Issue #44).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PenAction {
|
||||||
|
/// Pen comes near the screen, moves above it or leaves it
|
||||||
|
HoverEnter,
|
||||||
|
HoverMove,
|
||||||
|
HoverExit,
|
||||||
|
/// Pen touches the screen, moves on it, is lifted or the gesture is cancelled
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: one sample of a pen (tool type stylus or eraser). Such pointers are not
|
||||||
|
/// reported as `WindowEvent::Touch`; the app collects them with [`take_pen_samples`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct PenSample {
|
||||||
|
pub action: PenAction,
|
||||||
|
/// Position in physical pixels
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
/// Usually 0..=1
|
||||||
|
pub pressure: f32,
|
||||||
|
/// Eraser end of the pen (tool type eraser)
|
||||||
|
pub eraser: bool,
|
||||||
|
/// `MotionEvent.getButtonState()`; the pen buttons are 0x20 (primary) and 0x40 (secondary)
|
||||||
|
pub buttons: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: the pen samples received since the last call, oldest first.
|
||||||
|
pub fn take_pen_samples() -> Vec<PenSample> {
|
||||||
|
crate::platform_impl::take_pen_samples()
|
||||||
|
}
|
||||||
|
|
||||||
/// For compatibility applications should then import the `AndroidApp` type for
|
/// For compatibility applications should then import the `AndroidApp` type for
|
||||||
/// their `android_main(app: AndroidApp)` function like:
|
/// their `android_main(app: AndroidApp)` function like:
|
||||||
/// ```rust
|
/// ```rust
|
||||||
|
|||||||
+68
-2
@@ -11,7 +11,7 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction};
|
use android_activity::input::{InputEvent, KeyAction, Keycode, MotionAction, ToolType};
|
||||||
use android_activity::{
|
use android_activity::{
|
||||||
AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect,
|
AndroidApp, AndroidAppWaker, ConfigurationRef, InputStatus, MainEvent, Rect,
|
||||||
};
|
};
|
||||||
@@ -382,6 +382,7 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
InputEvent::MotionEvent(motion_event) => {
|
InputEvent::MotionEvent(motion_event) => {
|
||||||
let window_id = window::WindowId(WindowId);
|
let window_id = window::WindowId(WindowId);
|
||||||
let device_id = event::DeviceId(DeviceId(motion_event.device_id()));
|
let device_id = event::DeviceId(DeviceId(motion_event.device_id()));
|
||||||
|
record_pen_samples(motion_event);
|
||||||
|
|
||||||
let phase = match motion_event.action() {
|
let phase = match motion_event.action() {
|
||||||
MotionAction::Down | MotionAction::PointerDown => {
|
MotionAction::Down | MotionAction::PointerDown => {
|
||||||
@@ -408,6 +409,10 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for pointer in pointers {
|
for pointer in pointers {
|
||||||
|
// ink-notes patch: pens are reported through `take_pen_samples`
|
||||||
|
if is_pen(&pointer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let location = PhysicalPosition {
|
let location = PhysicalPosition {
|
||||||
x: pointer.x() as _,
|
x: pointer.x() as _,
|
||||||
y: pointer.y() as _,
|
y: pointer.y() as _,
|
||||||
@@ -450,6 +455,15 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
&mut self.combining_accent,
|
&mut self.combining_accent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ink-notes patch: the character typed, so that text fields receive it
|
||||||
|
let text = match (state, &key_char) {
|
||||||
|
(
|
||||||
|
event::ElementState::Pressed,
|
||||||
|
Some(android_activity::input::KeyMapChar::Unicode(c)),
|
||||||
|
) if !c.is_control() => Some(smol_str::SmolStr::from_iter([*c])),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
let event = event::Event::WindowEvent {
|
let event = event::Event::WindowEvent {
|
||||||
window_id: window::WindowId(WindowId),
|
window_id: window::WindowId(WindowId),
|
||||||
event: event::WindowEvent::KeyboardInput {
|
event: event::WindowEvent::KeyboardInput {
|
||||||
@@ -460,7 +474,7 @@ impl<T: 'static> EventLoop<T> {
|
|||||||
logical_key: keycodes::to_logical(key_char, keycode),
|
logical_key: keycodes::to_logical(key_char, keycode),
|
||||||
location: keycodes::to_location(keycode),
|
location: keycodes::to_location(keycode),
|
||||||
repeat: key.repeat_count() > 0,
|
repeat: key.repeat_count() > 0,
|
||||||
text: None,
|
text,
|
||||||
platform_specific: KeyEventExtra {},
|
platform_specific: KeyEventExtra {},
|
||||||
},
|
},
|
||||||
is_synthetic: false,
|
is_synthetic: false,
|
||||||
@@ -1124,3 +1138,55 @@ impl VideoMode {
|
|||||||
self.monitor.clone()
|
self.monitor.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: pen samples waiting for `take_pen_samples` (Issue #44)
|
||||||
|
static PEN_SAMPLES: Mutex<Vec<crate::platform::android::PenSample>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
fn is_pen(pointer: &android_activity::input::Pointer<'_>) -> bool {
|
||||||
|
matches!(pointer.tool_type(), ToolType::Stylus | ToolType::Eraser)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_pen_samples(motion_event: &android_activity::input::MotionEvent<'_>) {
|
||||||
|
use crate::platform::android::{PenAction, PenSample};
|
||||||
|
|
||||||
|
let action = match motion_event.action() {
|
||||||
|
MotionAction::Down | MotionAction::PointerDown => PenAction::Down,
|
||||||
|
MotionAction::Up | MotionAction::PointerUp => PenAction::Up,
|
||||||
|
MotionAction::Move => PenAction::Move,
|
||||||
|
MotionAction::Cancel => PenAction::Cancel,
|
||||||
|
MotionAction::HoverEnter => PenAction::HoverEnter,
|
||||||
|
MotionAction::HoverMove => PenAction::HoverMove,
|
||||||
|
MotionAction::HoverExit => PenAction::HoverExit,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let buttons = motion_event.button_state().0;
|
||||||
|
let mut record = |pointer: android_activity::input::Pointer<'_>| {
|
||||||
|
if !is_pen(&pointer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sample = PenSample {
|
||||||
|
action,
|
||||||
|
x: pointer.x(),
|
||||||
|
y: pointer.y(),
|
||||||
|
pressure: pointer.pressure(),
|
||||||
|
eraser: pointer.tool_type() == ToolType::Eraser,
|
||||||
|
buttons,
|
||||||
|
};
|
||||||
|
let mut samples = PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Nobody collects them (app without pen support): don't grow without bound
|
||||||
|
if samples.len() >= 4096 {
|
||||||
|
samples.remove(0);
|
||||||
|
}
|
||||||
|
samples.push(sample);
|
||||||
|
};
|
||||||
|
match action {
|
||||||
|
PenAction::Down | PenAction::Up => {
|
||||||
|
record(motion_event.pointer_at_index(motion_event.pointer_index()))
|
||||||
|
}
|
||||||
|
_ => motion_event.pointers().for_each(record),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_pen_samples() -> Vec<crate::platform::android::PenSample> {
|
||||||
|
std::mem::take(&mut *PEN_SAMPLES.lock().unwrap_or_else(|e| e.into_inner()))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user