Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c436fb28c | ||
|
|
7584827c04 | ||
|
|
0be321837c | ||
|
|
a8aef30db7 | ||
|
|
4ca21483e5 | ||
|
|
c95cde8ec8 | ||
|
|
6b80cd672a | ||
|
|
7faf78ddeb | ||
|
|
ce3a1f697b | ||
|
|
e47c2b6f28 | ||
|
|
d610ca019a | ||
|
|
8034c88f8b | ||
|
|
1492f8affb | ||
|
|
e5a8601f87 | ||
|
|
beb780b420 | ||
|
|
9b34e30475 | ||
|
|
41ee32e1f7 | ||
|
|
397bf3bfe4 | ||
|
|
ca6f64ec8e | ||
|
|
46026eb8e5 | ||
|
|
2bd697ef82 | ||
|
|
b5d9d72e79 | ||
|
|
955eaa4095 | ||
|
|
330c29193f | ||
|
|
dd515787ec | ||
|
|
b11fb2d762 | ||
|
|
e8a7675f8d | ||
|
|
0992b9bc0e | ||
|
|
78554c77ee | ||
|
|
1e34c01ad2 |
No files matched your search
@@ -35,6 +35,7 @@ jobs:
|
|||||||
libxrandr-dev \
|
libxrandr-dev \
|
||||||
libxi-dev \
|
libxi-dev \
|
||||||
libxkbcommon-dev \
|
libxkbcommon-dev \
|
||||||
|
gcc-mingw-w64-x86-64 \
|
||||||
squashfs-tools \
|
squashfs-tools \
|
||||||
dpkg-dev \
|
dpkg-dev \
|
||||||
curl \
|
curl \
|
||||||
@@ -62,7 +63,7 @@ jobs:
|
|||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
|
- name: Build Release Artifacts (.deb, .AppImage, .exe, SHA256SUMS.txt + .minisig)
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/build-release.sh
|
./scripts/build-release.sh
|
||||||
@@ -78,3 +79,60 @@ 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:
|
||||||
|
# Experimental (Issue #45): builds the APK and keeps it as a workflow artifact. It is not
|
||||||
|
# part of the release yet, and a failure here does not stop the release above.
|
||||||
|
name: Android APK (experimental)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
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: Keep APK as Artifact
|
||||||
|
uses: actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: ink-notes-android-apk
|
||||||
|
path: target/ink-notes_*_arm64.apk
|
||||||
Generated
+408
-124
@@ -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"
|
||||||
@@ -85,6 +96,24 @@ 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 = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
|
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_log-sys"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_logger"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f"
|
||||||
|
dependencies = [
|
||||||
|
"android_log-sys",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -201,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"
|
||||||
@@ -241,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"
|
||||||
@@ -316,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"
|
||||||
@@ -352,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"
|
||||||
@@ -436,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"
|
||||||
@@ -482,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"
|
||||||
@@ -491,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"
|
||||||
@@ -522,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"
|
||||||
@@ -540,10 +636,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "data-url"
|
||||||
version = "0.5.8"
|
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 = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
@@ -551,8 +647,19 @@ 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]]
|
||||||
@@ -606,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"
|
||||||
@@ -693,6 +809,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_logger"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "epaint"
|
name = "epaint"
|
||||||
version = "0.27.2"
|
version = "0.27.2"
|
||||||
@@ -790,6 +915,12 @@ dependencies = [
|
|||||||
"zlib-rs",
|
"zlib-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-cmp"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -927,6 +1058,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -936,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]]
|
||||||
@@ -1237,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"
|
||||||
@@ -1381,6 +1522,12 @@ dependencies = [
|
|||||||
"quick-error",
|
"quick-error",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imagesize"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.2"
|
version = "2.14.2"
|
||||||
@@ -1393,28 +1540,35 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.33.0"
|
version = "0.39.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
|
"android_logger",
|
||||||
"arboard",
|
"arboard",
|
||||||
|
"base64",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
"egui-winit",
|
"egui-winit",
|
||||||
|
"flate2",
|
||||||
"fs2",
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
"hayro",
|
"hayro",
|
||||||
"hayro-svg",
|
"hayro-svg",
|
||||||
"image",
|
"image",
|
||||||
|
"jni",
|
||||||
|
"log",
|
||||||
|
"lopdf",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"naga",
|
"naga",
|
||||||
"octotablet",
|
"octotablet",
|
||||||
"pollster",
|
"pollster",
|
||||||
"printpdf",
|
"quick-xml",
|
||||||
|
"resvg",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"ureq",
|
"ureq",
|
||||||
"url",
|
"url",
|
||||||
@@ -1426,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"
|
||||||
@@ -1570,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"
|
||||||
@@ -1617,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]]
|
||||||
@@ -1642,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"
|
||||||
@@ -1776,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"
|
||||||
@@ -1961,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]]
|
||||||
@@ -2079,6 +2245,12 @@ dependencies = [
|
|||||||
"pxfm",
|
"pxfm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pico-args"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2139,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"
|
||||||
@@ -2157,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"
|
||||||
@@ -2247,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"
|
||||||
@@ -2314,18 +2482,39 @@ 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"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "resvg"
|
||||||
|
version = "0.49.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"log",
|
||||||
|
"pico-args",
|
||||||
|
"rgb",
|
||||||
|
"svgtypes",
|
||||||
|
"tiny-skia 0.12.0",
|
||||||
|
"usvg",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rgb"
|
||||||
|
version = "0.8.53"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2340,6 +2529,15 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "roxmltree"
|
||||||
|
version = "0.21.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -2450,7 +2648,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"smithay-client-toolkit",
|
"smithay-client-toolkit",
|
||||||
"tiny-skia",
|
"tiny-skia 0.11.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2509,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]]
|
||||||
@@ -2531,6 +2740,15 @@ 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 = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simplecss"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -2638,6 +2856,20 @@ name = "strict-num"
|
|||||||
version = "0.1.1"
|
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 = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||||
|
dependencies = [
|
||||||
|
"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"
|
||||||
@@ -2667,6 +2899,16 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "svgtypes"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
|
||||||
|
dependencies = [
|
||||||
|
"kurbo",
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
@@ -2783,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"
|
||||||
@@ -2828,7 +3040,22 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"log",
|
"log",
|
||||||
"tiny-skia-path",
|
"tiny-skia-path 0.11.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-skia"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"arrayvec",
|
||||||
|
"bytemuck",
|
||||||
|
"cfg-if",
|
||||||
|
"log",
|
||||||
|
"png",
|
||||||
|
"tiny-skia-path 0.12.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2842,6 +3069,17 @@ dependencies = [
|
|||||||
"strict-num",
|
"strict-num",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-skia-path"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
|
||||||
|
dependencies = [
|
||||||
|
"arrayref",
|
||||||
|
"bytemuck",
|
||||||
|
"strict-num",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -2852,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"
|
||||||
@@ -2891,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"
|
||||||
@@ -2918,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"
|
||||||
@@ -2978,6 +3237,25 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usvg"
|
||||||
|
version = "0.49.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c"
|
||||||
|
dependencies = [
|
||||||
|
"data-url",
|
||||||
|
"imagesize",
|
||||||
|
"kurbo",
|
||||||
|
"log",
|
||||||
|
"pico-args",
|
||||||
|
"roxmltree",
|
||||||
|
"simplecss",
|
||||||
|
"siphasher",
|
||||||
|
"strict-num",
|
||||||
|
"svgtypes",
|
||||||
|
"tiny-skia-path 0.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3270,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"
|
||||||
|
|||||||
+34
-5
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.33.0"
|
version = "0.39.3"
|
||||||
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"
|
||||||
|
|
||||||
@@ -38,24 +38,53 @@ semver = "1.0"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
octotablet = "0.1.0"
|
|
||||||
|
|
||||||
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
||||||
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
|
||||||
minisign-verify = "0.3.0"
|
minisign-verify = "0.3.0"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
hayro = "0.8"
|
hayro = "0.8"
|
||||||
hayro-svg = "0.8"
|
hayro-svg = "0.8"
|
||||||
|
|
||||||
|
# Import von Xournal++-Notizbüchern (gzip-komprimiertes XML mit base64-Bildern)
|
||||||
|
flate2 = "1.1"
|
||||||
|
quick-xml = "0.41"
|
||||||
|
base64 = "0.22"
|
||||||
|
# Rnote-Import: SVG-Bilder (z. B. PDF-Seiten) rastern
|
||||||
|
resvg = { version = "0.49", default-features = false, features = ["raster-images"] }
|
||||||
|
|
||||||
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
# Text-Werkzeug: Schriftmetriken und Rasterung (Schriften aus egui)
|
||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
|
# Pen input with pressure on Linux (Wayland/X11) and Windows (Windows Ink)
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
octotablet = "0.1.0"
|
||||||
|
# Systemzwischenablage (Bilder und Text)
|
||||||
|
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
||||||
|
|
||||||
# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs)
|
# Bildschirmtastatur unter Wayland anfordern (eigenes text-input-Objekt, siehe src/osk.rs)
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
wayland-client = "0.31"
|
wayland-client = "0.31"
|
||||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
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
|
||||||
|
# cdylib and packed into an APK by scripts/package-android.sh
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
winit = { version = "0.29", features = ["rwh_06", "android-native-activity"] }
|
||||||
|
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"
|
||||||
|
|
||||||
|
[package.metadata.android]
|
||||||
|
package = "eu.pansi.inknotes"
|
||||||
|
apk_name = "ink-notes"
|
||||||
|
|
||||||
|
[package.metadata.android.sdk]
|
||||||
|
min_sdk_version = 26
|
||||||
|
target_sdk_version = 34
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -11,9 +11,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
|
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).
|
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). Eine Windows-Version gibt es als experimentelles portables Programm.
|
||||||
|
|
||||||
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“.
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
- **Glättung in zwei Stufen:** Während des Schreibens glättet ein 1€-Filter das Zittern, die Linie erscheint sofort. Beim Absetzen wird der Strich nachbearbeitet: Ecken (Knicke über 65°) bleiben erhalten und werden rund verbunden, der Druckverlauf wird geglättet, überflüssige Punkte entfallen, die Enden werden rund.
|
||||||
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
- **Druckkurve** im ☰-Menü an den eigenen Stift anpassen (weicher/härter, Schwelle gegen Grundrauschen).
|
||||||
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
- **Tasten am Stift** im ☰-Menü belegen: Radieren (Standard), Auswählen, Textmarker, Stift oder nichts – gilt, solange die Taste gedrückt ist. Jede Taste erscheint dort nach dem ersten Drücken.
|
||||||
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker, in vier Größen (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
|
- **Radierer** in drei Modi: ganze Striche, nur den überstrichenen Teil (Teilstrich) oder nur Textmarker; die Größe stellt man in einem Menü wie bei den Stiften ein (wird gemerkt). Die Stiftrückseite radiert ebenfalls.
|
||||||
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
- **Virtuelles Lineal** (`L`): verschieben, am Griff drehen (rastet bei 15° ein); Striche entlang einer Kante werden gerade.
|
||||||
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
|
- **Nur ansehen** (👁 in der Werkzeugleiste): Stift, Finger und Maus scrollen und zoomen nur; die Werkzeuge verschwinden, nichts lässt sich versehentlich ändern. Erneut 👁 tippen, um zu schreiben.
|
||||||
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
|
- **Platz schaffen** (↕ in der Werkzeugleiste): aufs Blatt tippen und nach unten ziehen – alles darunter rutscht mit, auch auf den folgenden Seiten. Was über ein Seitenende rutschen würde, beginnt auf der nächsten Seite; fehlende Seiten werden angehängt. Nach oben ziehen schließt leeren Platz. Ein Schritt für Rückgängig.
|
||||||
@@ -47,7 +47,10 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
- **Text-Werkzeug** (`T`): ins Blatt tippen und schreiben – normale oder Code-Schrift (feste Zeichenbreite, Tab rückt ein), 8–36 pt, Zeilenumbruch am Feldrand. Vorhandenen Text antippen zum Ändern, `Esc` beendet. Textfelder lassen sich wie Bilder verschieben, drehen und skalieren. In anderen Programmen kopierter Text wird mit `Strg + V` zum Textfeld.
|
||||||
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
- **Bilder** (PNG, JPEG, GIF, WebP, BMP) über `Strg + I`, aus der Zwischenablage oder per Ziehen ins Fenster einfügen.
|
||||||
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
- **Einfügen per Finger:** Lange (½ s) mit dem Finger aufs Blatt drücken öffnet ein Menü „Einfügen“ – Screenshot, kopierter Text oder kopierte Objekte landen genau an dieser Stelle.
|
||||||
- **PDF importieren** (☰-Menü): Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
- **Importieren** (☰-Menü oder Datei ins Fenster ziehen) – **PDF**: Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
|
||||||
|
**Xournal++** (`.xopp`, auch `.xoj` aus Xournal): Striche mit Stiftdruck, Textmarker, Texte, Bilder, liniertes/kariertes/gepunktetes Papier und PDF-Hintergründe werden übernommen (das PDF wird am alten Ort und neben der Datei gesucht). LaTeX-Formeln werden übersprungen.
|
||||||
|
**Rnote** (`.rnote`, ab Rnote 0.5.10): Striche mit Stiftdruck, Formen, Texte und Bilder; in Rnote importierte PDF-Seiten werden zu Bildern. Seiten bleiben Seiten, unendliche Dokumente werden ein endloses Notizbuch.
|
||||||
|
**Saber** (`.sbn2` mit den Asset-Dateien `.sbn2.0`, `.sbn2.1`, … daneben, `.sba`-Archive, altes `.sbn`): Striche mit Stiftdruck, Textmarker, Formen, getippter Text, Bilder und PDF-Hintergründe.
|
||||||
|
|
||||||
### Seiten und Ansicht
|
### Seiten und Ansicht
|
||||||
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
|
||||||
@@ -65,6 +68,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||||
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
||||||
|
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
||||||
@@ -83,7 +87,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` und `.AppImage` (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` und für Windows als `.exe` (x86_64). `<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)
|
||||||
@@ -98,6 +102,8 @@ Hinweise:
|
|||||||
- Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
|
- Benötigt `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) und einen Grafiktreiber mit Vulkan oder OpenGL.
|
||||||
- Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
|
- Meldet das AppImage einen Fehler zu `libfuse.so.2`, entweder `sudo apt install libfuse2t64` installieren oder mit `--appimage-extract-and-run` starten.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tastenkürzel
|
## Tastenkürzel
|
||||||
@@ -148,12 +154,17 @@ cargo test # Unit- und Integrationstests
|
|||||||
|
|
||||||
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`.
|
Die CI prüft zusätzlich `cargo fmt --check` und `cargo clippy --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
Andere Systeme:
|
||||||
|
- **Windows** wird unter Linux übersetzt: `scripts/package-windows.sh` (braucht `gcc-mingw-w64-x86-64`); die Tests laufen mit `wine` als Runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||||
|
- **Android** (experimentell, Issue #42): `scripts/package-android.sh` baut `target/ink-notes_<version>_arm64.apk` ohne Gradle. Nötig sind das Android-SDK (build-tools, Plattform 34; `ANDROID_HOME`, Standard `~/android-sdk`) und das NDK; der Signierschlüssel kommt aus `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`, sonst wird der Debug-Schlüssel verwendet. Ohne SDK auf dem Rechner baut `scripts/build-apk-container.sh` sie in einem Container (podman oder docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installiert die festen SDK-/NDK-Versionen. Die CI baut die APK als Workflow-Artefakt (noch nicht im Release); ein Release-Schlüssel gehört in die Secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` und `ANDROID_KEY_ALIAS`. Installieren mit `adb install -r target/ink-notes_<version>_arm64.apk`, Log-Ausgaben mit `adb logcat -s ink-notes`. Der Stift wird von Fingern unterschieden (Stifttasten, Radierer-Ende, Handballen-Erkennung), Textfelder öffnen die Bildschirmtastatur, Import und „Bild einfügen“ nutzen den System-Dateidialog, und Exporte landen in `Download/ink-notes` (ab Android 10). Tastaturtext und Dateidialog kommen von der kleinen Java-Activity `packaging/android/java/…/InkActivity.java` (zum Bauen ist ein JDK nötig).
|
||||||
|
|
||||||
### Projektstruktur
|
### Projektstruktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── main.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
├── main.rs # Einstieg für den Desktop, startet app::run
|
||||||
├── lib.rs # Bibliotheks-Exporte (für Tests)
|
├── app.rs # Event-Loop (winit), Eingaben von Maus, Touch und Stift, Ablaufsteuerung
|
||||||
|
├── lib.rs # Bibliotheks-Exporte (App und Module, auch für Tests)
|
||||||
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
|
├── canvas.rs # wgpu-Rendering: Papier, Muster, Bilder, Tinte, Nachbarseiten
|
||||||
├── shader.wgsl # Vertex- und Fragment-Shader
|
├── shader.wgsl # Vertex- und Fragment-Shader
|
||||||
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
|
├── viewport.rs # Kamera (Zoom, Verschieben), Seitenstapel der durchgehenden Ansicht
|
||||||
@@ -168,16 +179,23 @@ 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)
|
||||||
|
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
|
||||||
|
├── rnote.rs # Import von Rnote-Notizbüchern
|
||||||
|
├── saber.rs # Import von Saber-Notizbüchern
|
||||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── history.rs # Rückgängig/Wiederholen
|
||||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
||||||
|
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
||||||
├── library.rs # Ordner der Notizbücher (library.json)
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
├── 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)
|
||||||
├── platform.rs # Wayland/X11-Erkennung
|
├── platform.rs # Wayland/X11-Erkennung, Unterschiede zwischen Linux, Windows und Android
|
||||||
|
├── paths.rs # Ordner für Daten, Einstellungen und Downloads je System
|
||||||
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung)
|
├── updater.rs # Updates von Gitea (Signatur- und Prüfsummenprüfung)
|
||||||
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge
|
└── ui.rs # Oberfläche (egui): Werkzeugleiste, Menüs, Dialoge
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation, 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).
|
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11). A Windows version is available as an experimental portable program.
|
||||||
|
|
||||||
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".
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
|
- **Two-stage smoothing:** While you write, a 1€ filter removes jitter and the line appears immediately. When you lift the pen, the stroke is refined: corners (bends over 65°) are kept and drawn round, the pressure curve is smoothed, redundant points are dropped and the ends become round.
|
||||||
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
|
- **Pressure curve** adjustable to your pen in the ☰ menu (softer/harder, threshold against base noise).
|
||||||
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
|
- **Pen buttons** can be assigned in the ☰ menu: erase (default), select, highlighter, pen or nothing – active while the button is held. Each button appears there after it is pressed for the first time.
|
||||||
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only, in four sizes (remembered). The back end of the pen erases too.
|
- **Eraser** with three modes: whole strokes, only the part you wipe over (partial stroke), or highlighter only; its size is set in a menu like the pens (remembered). The back end of the pen erases too.
|
||||||
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
|
- **Virtual ruler** (`L`): move it, rotate it by its handle (snaps to 15°); strokes along an edge become straight.
|
||||||
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
|
- **View only** (👁 in the toolbar): pen, finger and mouse only scroll and zoom; the tools disappear and nothing can be changed by accident. Tap 👁 again to write.
|
||||||
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
|
- **Make space** (↕ in the toolbar): tap the page and drag down – everything below moves along, also on the following pages. Whatever would slide past the end of a page continues on the next one; missing pages are appended. Dragging up closes empty space. Undo reverts it in one step.
|
||||||
@@ -47,7 +47,10 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`.
|
- **Text tool** (`T`): tap the page and type – regular or code font (fixed character width, Tab indents), 8–36 pt, line wrapping at the box edge. Tap existing text to change it, `Esc` finishes. Text boxes can be moved, rotated and scaled like images. Text copied in other programs becomes a text box with `Ctrl + V`.
|
||||||
- **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window.
|
- **Images** (PNG, JPEG, GIF, WebP, BMP) via `Ctrl + I`, from the clipboard, or by dragging them into the window.
|
||||||
- **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot.
|
- **Paste with a finger:** Pressing the page with a finger for half a second opens a "Paste" menu – a screenshot, copied text or copied objects land exactly at that spot.
|
||||||
- **Import PDF** (☰ menu): Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
|
- **Import** (☰ menu, or drag the file into the window) – **PDF**: Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
|
||||||
|
**Xournal++** (`.xopp`, also `.xoj` from Xournal): strokes with pen pressure, highlighters, texts, images, lined/squared/dotted paper and PDF backgrounds are taken over (the PDF is looked for at its old place and next to the file). LaTeX formulas are skipped.
|
||||||
|
**Rnote** (`.rnote`, from Rnote 0.5.10): strokes with pen pressure, shapes, texts and images; PDF pages imported in Rnote become images. Pages stay pages, infinite documents become an endless notebook.
|
||||||
|
**Saber** (`.sbn2` with its asset files `.sbn2.0`, `.sbn2.1`, … next to it, `.sba` archives, old `.sbn`): strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.
|
||||||
|
|
||||||
### Pages and view
|
### Pages and view
|
||||||
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
|
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
|
||||||
@@ -65,6 +68,7 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
|
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
|
||||||
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
||||||
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
||||||
|
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
|
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
|
||||||
@@ -83,7 +87,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` and `.AppImage` (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` and for Windows as `.exe` (x86_64). `<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)
|
||||||
@@ -98,6 +102,8 @@ Notes:
|
|||||||
- Requires `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) and a graphics driver with Vulkan or OpenGL.
|
- Requires `glibc >= 2.39` (Ubuntu 24.04, Zorin OS 18) and a graphics driver with Vulkan or OpenGL.
|
||||||
- If the AppImage reports an error about `libfuse.so.2`, either install `sudo apt install libfuse2t64` or start it with `--appimage-extract-and-run`.
|
- If the AppImage reports an error about `libfuse.so.2`, either install `sudo apt install libfuse2t64` or start it with `--appimage-extract-and-run`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
@@ -148,14 +154,19 @@ cargo test # unit and integration tests
|
|||||||
|
|
||||||
CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings`.
|
CI additionally checks `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings`.
|
||||||
|
|
||||||
|
Other systems:
|
||||||
|
- **Windows** is cross-compiled on Linux: `scripts/package-windows.sh` (needs `gcc-mingw-w64-x86-64`); the tests run with `wine` as runner (`CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine cargo test --target x86_64-pc-windows-gnu`).
|
||||||
|
- **Android** (experimental, Issue #42): `scripts/package-android.sh` builds `target/ink-notes_<version>_arm64.apk` without Gradle. It needs the Android SDK (build-tools, platform 34; `ANDROID_HOME`, default `~/android-sdk`) and the NDK; the signing key comes from `ANDROID_KEYSTORE`, `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`, otherwise the debug key is used. Without SDK on the host, `scripts/build-apk-container.sh` builds it in a container (podman or docker, `packaging/android/Containerfile`); `scripts/setup-android-sdk.sh` installs the fixed SDK/NDK versions. The CI builds the APK as a workflow artifact (not yet part of the release); a release key goes into the secrets `ANDROID_KEYSTORE_B64` (`base64 -w0 release.keystore`), `ANDROID_KEYSTORE_PASS` and `ANDROID_KEY_ALIAS`. Install with `adb install -r target/ink-notes_<version>_arm64.apk`, log output with `adb logcat -s ink-notes`. The pen is told apart from fingers (pen buttons, eraser end, palm rejection), text fields open the soft keyboard, import and inserting images use the system file dialog, and exports are saved to `Download/ink-notes` (Android 10 and newer). The small Java activity `packaging/android/java/…/InkActivity.java` (needs a JDK to build) provides keyboard text and the file dialog.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
### Project structure
|
### Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── main.rs # Event loop (winit), mouse, touch and pen input, control flow
|
├── main.rs # Desktop entry point, starts app::run
|
||||||
├── lib.rs # Library exports (for tests)
|
├── app.rs # Event loop (winit), mouse, touch and pen input, control flow
|
||||||
|
├── lib.rs # Library exports (app and modules, also for tests)
|
||||||
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
|
├── canvas.rs # wgpu rendering: paper, patterns, images, ink, neighboring pages
|
||||||
├── shader.wgsl # Vertex and fragment shaders
|
├── shader.wgsl # Vertex and fragment shaders
|
||||||
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
|
├── viewport.rs # Camera (zoom, pan), page stack of the continuous view, swipe momentum
|
||||||
@@ -170,16 +181,23 @@ 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)
|
||||||
|
├── xopp.rs # Import of Xournal++/Xournal notebooks
|
||||||
|
├── rnote.rs # Import of Rnote notebooks
|
||||||
|
├── saber.rs # Import of Saber notebooks
|
||||||
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||||
├── history.rs # Undo/redo
|
├── history.rs # Undo/redo
|
||||||
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
||||||
|
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
||||||
├── library.rs # Notebook folders (library.json)
|
├── library.rs # Notebook folders (library.json)
|
||||||
├── 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)
|
||||||
├── platform.rs # Wayland/X11 detection
|
├── platform.rs # Wayland/X11 detection, differences between Linux, Windows and Android
|
||||||
|
├── paths.rs # Folders for data, settings and downloads per system
|
||||||
├── updater.rs # Updates from Gitea (signature and checksum verification)
|
├── updater.rs # Updates from Gitea (signature and checksum verification)
|
||||||
└── ui.rs # Interface (egui): toolbar, menus, dialogs
|
└── ui.rs # Interface (egui): toolbar, menus, dialogs
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,56 +127,96 @@
|
|||||||
|
|
||||||
<!-- ==================== 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</text>
|
||||||
</g>
|
</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>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 11 KiB |
+74
-28
@@ -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,56 +127,96 @@
|
|||||||
|
|
||||||
<!-- ==================== 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</text>
|
||||||
</g>
|
</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>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 11 KiB |
@@ -15,7 +15,7 @@ ink-notes ist ein einzelnes Programm mit einem Event-Loop (`winit`) im Haupt-Thr
|
|||||||
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
|
| Haupt-Thread | Eingaben, Datenmodell, Rendering (`wgpu`), Oberfläche (`egui`) |
|
||||||
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
|
| Autosave | Seiten und Manifest auf die Festplatte schreiben (`autosave.rs`) |
|
||||||
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
|
| PDF-Rendering | Importierte PDF-Seiten zu Texturen rendern (`pdf_doc.rs`, `hayro`) |
|
||||||
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`main.rs`) |
|
| Export | PDF/PNG/SVG-Export, damit die Oberfläche nicht hängt (`app.rs`) |
|
||||||
|
|
||||||
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
|
Stifteingaben kommen über `octotablet` (Tablet-Protokoll von Wayland bzw. X11), Maus und Touch über `winit`. Die Oberfläche (Werkzeugleiste, Menüs, Dialoge, Textbearbeitung) ist mit `egui` gebaut und wird im selben Render-Durchgang über das Blatt gezeichnet.
|
||||||
|
|
||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ink-notes is a single program with an event loop (`winit`) on the main thread, p
|
|||||||
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
|
| Main thread | Input, data model, rendering (`wgpu`), interface (`egui`) |
|
||||||
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
|
| Autosave | Writes pages and manifest to disk (`autosave.rs`) |
|
||||||
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
|
| PDF rendering | Renders imported PDF pages to textures (`pdf_doc.rs`, `hayro`) |
|
||||||
| Export | PDF/PNG/SVG export, so the interface does not freeze (`main.rs`) |
|
| Export | PDF/PNG/SVG export, so the interface does not freeze (`app.rs`) |
|
||||||
|
|
||||||
Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
|
Pen input arrives via `octotablet` (the Wayland or X11 tablet protocol), mouse and touch via `winit`. The interface (toolbar, menus, dialogs, text editing) is built with `egui` and drawn over the page in the same render pass.
|
||||||
|
|
||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
|
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
|
||||||
│ │ │ ├── <sha256>.png
|
│ │ │ ├── <sha256>.png
|
||||||
│ │ │ └── <sha256>.pdf
|
│ │ │ └── <sha256>.pdf
|
||||||
|
│ │ ├── .opened_by.json Gerät, auf dem das Notizbuch geöffnet ist (siehe unten)
|
||||||
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
|
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
|
||||||
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
|
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
|
||||||
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
|
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
|
||||||
@@ -28,6 +29,10 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
|
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Unter Windows liegt dieselbe Struktur in `%APPDATA%\ink-notes` (Notizbücher, `recovery`, `ui.json`); heruntergeladene Updates landen in `%LOCALAPPDATA%\ink-notes`.
|
||||||
|
|
||||||
|
Der Ordner `notebooks/` lässt sich in den Einstellungen verlegen (`storage_dir` in `ui.json`), z. B. nach `~/Nextcloud/ink-notes`; `recovery/` bleibt, wo es ist.
|
||||||
|
|
||||||
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
|
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
|
||||||
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
||||||
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
||||||
@@ -167,6 +172,12 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder
|
|||||||
### Gelöschte Seiten
|
### Gelöschte Seiten
|
||||||
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
|
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
|
||||||
|
|
||||||
|
### Abgleich zwischen Geräten
|
||||||
|
Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing o. Ä. abgleichen:
|
||||||
|
- `.opened_by.json` (`{"device": "<Rechnername>", "since": <Unix-Zeit>}`) wird beim Öffnen eines Notizbuchs geschrieben und beim Schließen entfernt. Eine Markierung eines anderen Geräts führt zu einer Warnung; die Sperrdatei `.lock` schützt nur vor einer zweiten Instanz auf demselben Rechner.
|
||||||
|
- Solange alle lokalen Änderungen gespeichert sind, prüft die App den Ordner alle 3 Sekunden (und wenn das Fenster den Fokus bekommt). Geänderte Seitendateien werden neu geladen, ein geändertes `manifest.json` bringt neue, entfernte oder umsortierte Seiten und Titel mit.
|
||||||
|
- Konfliktkopien der Sync-Clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) werden als neue Seiten hinter dem Original eingefügt und in `<name>.imported` umbenannt.
|
||||||
|
|
||||||
### Neuere Versionen
|
### Neuere Versionen
|
||||||
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ This document describes where and how ink-notes stores notebooks, and the versio
|
|||||||
│ │ ├── assets/ inserted images and imported PDFs
|
│ │ ├── assets/ inserted images and imported PDFs
|
||||||
│ │ │ ├── <sha256>.png
|
│ │ │ ├── <sha256>.png
|
||||||
│ │ │ └── <sha256>.pdf
|
│ │ │ └── <sha256>.pdf
|
||||||
|
│ │ ├── .opened_by.json device that has the notebook open (see below)
|
||||||
│ │ └── .lock lock file while the notebook is open
|
│ │ └── .lock lock file while the notebook is open
|
||||||
│ └── .trash/ deleted notebooks (<id>_<timestamp>)
|
│ └── .trash/ deleted notebooks (<id>_<timestamp>)
|
||||||
└── recovery/ emergency backups of unsaved pages
|
└── recovery/ emergency backups of unsaved pages
|
||||||
@@ -28,6 +29,10 @@ This document describes where and how ink-notes stores notebooks, and the versio
|
|||||||
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) interface settings
|
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) interface settings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Windows the same structure lies in `%APPDATA%\ink-notes` (notebooks, `recovery`, `ui.json`); downloaded updates go to `%LOCALAPPDATA%\ink-notes`.
|
||||||
|
|
||||||
|
The folder `notebooks/` can be moved elsewhere in the settings (`storage_dir` in `ui.json`), e.g. to `~/Nextcloud/ink-notes`; `recovery/` stays where it is.
|
||||||
|
|
||||||
- **Notebook IDs** are built from the title and the time (e.g. `mathematik_1775460000`); the first notebook is called `default`.
|
- **Notebook IDs** are built from the title and the time (e.g. `mathematik_1775460000`); the first notebook is called `default`.
|
||||||
- **Assets** are named after the SHA-256 hash of their content; the same image is stored only once. Images are stored as PNG or (unchanged) JPEG, PDFs unchanged.
|
- **Assets** are named after the SHA-256 hash of their content; the same image is stored only once. Images are stored as PNG or (unchanged) JPEG, PDFs unchanged.
|
||||||
- All files are created with permissions `0600`, folders with `0700`.
|
- All files are created with permissions `0600`, folders with `0700`.
|
||||||
@@ -167,6 +172,12 @@ A crash while saving therefore leaves either the old or the new file, never a ha
|
|||||||
### Deleted pages
|
### Deleted pages
|
||||||
A deleted page disappears from `manifest.json`; its file `page_<id>.json` stays in the folder. Page IDs are never reused (`next_page_id`), so the file cannot collide with a new page. If the page is restored in the same session, the app writes it again.
|
A deleted page disappears from `manifest.json`; its file `page_<id>.json` stays in the folder. Page IDs are never reused (`next_page_id`), so the file cannot collide with a new page. If the page is restored in the same session, the app writes it again.
|
||||||
|
|
||||||
|
### Syncing between devices
|
||||||
|
If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthing or similar:
|
||||||
|
- `.opened_by.json` (`{"device": "<host name>", "since": <unix time>}`) is written when a notebook is opened and removed when it is closed. A marker from another device leads to a warning; the lock file `.lock` only protects against a second instance on the same computer.
|
||||||
|
- While all local changes are saved, the app checks the folder every 3 seconds (and when the window gets the focus). Changed page files are reloaded, a changed `manifest.json` brings in new, removed or reordered pages and titles.
|
||||||
|
- Conflict copies of sync clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) are added as new pages after the original and renamed to `<name>.imported`.
|
||||||
|
|
||||||
### Newer versions
|
### Newer versions
|
||||||
Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
|
Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
|
||||||
|
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<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">
|
||||||
|
<description>
|
||||||
|
<p>Windows: the mouse works again; Windows Ink no longer reports it as a pen.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.39.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>First experimental Windows version as a portable program. Internal groundwork for Android; nothing changes on Linux.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.38.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>The notebooks can be stored in a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox. Changes from other devices are taken over automatically, conflict copies become pages of their own, and a warning appears when a notebook is open on another device.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.37.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.36.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.35.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.2" date="2026-10-26">
|
||||||
|
<description>
|
||||||
|
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.1" date="2026-10-25">
|
||||||
|
<description>
|
||||||
|
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.0" date="2026-10-23">
|
||||||
|
<description>
|
||||||
|
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.33.0" date="2026-10-21">
|
<release version="0.33.0" date="2026-10-21">
|
||||||
<description>
|
<description>
|
||||||
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the 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",
|
||||||
@@ -116,6 +129,32 @@
|
|||||||
"dest": "cargo/vendor/android-properties-0.2.2",
|
"dest": "cargo/vendor/android-properties-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/android_log-sys/android_log-sys-0.3.2.crate",
|
||||||
|
"sha256": "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d",
|
||||||
|
"dest": "cargo/vendor/android_log-sys-0.3.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/android_log-sys-0.3.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/android_logger/android_logger-0.13.3.crate",
|
||||||
|
"sha256": "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f",
|
||||||
|
"dest": "cargo/vendor/android_logger-0.13.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/android_logger-0.13.3",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -311,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",
|
||||||
@@ -467,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",
|
||||||
@@ -532,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",
|
||||||
@@ -649,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",
|
||||||
@@ -714,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",
|
||||||
@@ -727,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",
|
||||||
@@ -779,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",
|
||||||
@@ -805,6 +961,19 @@
|
|||||||
"dest": "cargo/vendor/d3d12-0.19.0",
|
"dest": "cargo/vendor/d3d12-0.19.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/data-url/data-url-0.3.2.crate",
|
||||||
|
"sha256": "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376",
|
||||||
|
"dest": "cargo/vendor/data-url-0.3.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/data-url-0.3.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -831,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",
|
||||||
@@ -909,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",
|
||||||
@@ -1000,6 +1195,19 @@
|
|||||||
"dest": "cargo/vendor/enum_dispatch-0.3.13",
|
"dest": "cargo/vendor/enum_dispatch-0.3.13",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/env_logger/env_logger-0.10.2.crate",
|
||||||
|
"sha256": "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580",
|
||||||
|
"dest": "cargo/vendor/env_logger-0.10.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/env_logger-0.10.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1156,6 +1364,19 @@
|
|||||||
"dest": "cargo/vendor/flate2-1.1.10",
|
"dest": "cargo/vendor/flate2-1.1.10",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/float-cmp/float-cmp-0.9.0.crate",
|
||||||
|
"sha256": "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4",
|
||||||
|
"dest": "cargo/vendor/float-cmp-0.9.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/float-cmp-0.9.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1728,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",
|
||||||
@@ -1884,6 +2118,19 @@
|
|||||||
"dest": "cargo/vendor/image-webp-0.2.4",
|
"dest": "cargo/vendor/image-webp-0.2.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/imagesize/imagesize-0.15.0.crate",
|
||||||
|
"sha256": "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a",
|
||||||
|
"dest": "cargo/vendor/imagesize-0.15.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/imagesize-0.15.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -1897,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",
|
||||||
@@ -2183,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",
|
||||||
@@ -2209,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",
|
||||||
@@ -2391,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",
|
||||||
@@ -2794,6 +3093,19 @@
|
|||||||
"dest": "cargo/vendor/pic-scale-0.7.12",
|
"dest": "cargo/vendor/pic-scale-0.7.12",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/pico-args/pico-args-0.5.0.crate",
|
||||||
|
"sha256": "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315",
|
||||||
|
"dest": "cargo/vendor/pico-args-0.5.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/pico-args-0.5.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3067,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",
|
||||||
@@ -3080,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",
|
||||||
@@ -3197,6 +3548,32 @@
|
|||||||
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
|
"dest": "cargo/vendor/renderdoc-sys-1.1.0",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/resvg/resvg-0.49.0.crate",
|
||||||
|
"sha256": "1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8",
|
||||||
|
"dest": "cargo/vendor/resvg-0.49.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"1572844b6727a1bf842190e9947641a68ac44bed83f50afb4bd1407a2564bee8\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/resvg-0.49.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/rgb/rgb-0.8.53.crate",
|
||||||
|
"sha256": "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4",
|
||||||
|
"dest": "cargo/vendor/rgb-0.8.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/rgb-0.8.53",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3210,6 +3587,19 @@
|
|||||||
"dest": "cargo/vendor/ring-0.17.14",
|
"dest": "cargo/vendor/ring-0.17.14",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/roxmltree/roxmltree-0.21.2.crate",
|
||||||
|
"sha256": "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907",
|
||||||
|
"dest": "cargo/vendor/roxmltree-0.21.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/roxmltree-0.21.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3444,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",
|
||||||
@@ -3483,6 +3886,19 @@
|
|||||||
"dest": "cargo/vendor/simdutf8-0.1.5",
|
"dest": "cargo/vendor/simdutf8-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/simplecss/simplecss-0.2.2.crate",
|
||||||
|
"sha256": "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c",
|
||||||
|
"dest": "cargo/vendor/simplecss-0.2.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/simplecss-0.2.2",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3639,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",
|
||||||
@@ -3678,6 +4107,19 @@
|
|||||||
"dest": "cargo/vendor/subtle-2.6.1",
|
"dest": "cargo/vendor/subtle-2.6.1",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/svgtypes/svgtypes-0.16.1.crate",
|
||||||
|
"sha256": "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d",
|
||||||
|
"dest": "cargo/vendor/svgtypes-0.16.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/svgtypes-0.16.1",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3873,6 +4315,19 @@
|
|||||||
"dest": "cargo/vendor/tiny-skia-0.11.4",
|
"dest": "cargo/vendor/tiny-skia-0.11.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/tiny-skia/tiny-skia-0.12.0.crate",
|
||||||
|
"sha256": "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea",
|
||||||
|
"dest": "cargo/vendor/tiny-skia-0.12.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/tiny-skia-0.12.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3886,6 +4341,19 @@
|
|||||||
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
|
"dest": "cargo/vendor/tiny-skia-path-0.11.4",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/tiny-skia-path/tiny-skia-path-0.12.0.crate",
|
||||||
|
"sha256": "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9",
|
||||||
|
"dest": "cargo/vendor/tiny-skia-path-0.12.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/tiny-skia-path-0.12.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -3899,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",
|
||||||
@@ -4003,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",
|
||||||
@@ -4016,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",
|
||||||
@@ -4094,6 +4614,19 @@
|
|||||||
"dest": "cargo/vendor/url-2.5.8",
|
"dest": "cargo/vendor/url-2.5.8",
|
||||||
"dest-filename": ".cargo-checksum.json"
|
"dest-filename": ".cargo-checksum.json"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "archive",
|
||||||
|
"archive-type": "tar-gzip",
|
||||||
|
"url": "https://static.crates.io/crates/usvg/usvg-0.49.0.crate",
|
||||||
|
"sha256": "d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c",
|
||||||
|
"dest": "cargo/vendor/usvg-0.49.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "inline",
|
||||||
|
"contents": "{\"package\": \"d09b73ee83641d390ad7ffd9000f347b16eb128e437675b6ff277ea5c7fcf22c\", \"files\": {}}",
|
||||||
|
"dest": "cargo/vendor/usvg-0.49.0",
|
||||||
|
"dest-filename": ".cargo-checksum.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"archive-type": "tar-gzip",
|
"archive-type": "tar-gzip",
|
||||||
@@ -4445,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,61 @@
|
|||||||
</branding>
|
</branding>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
<releases>
|
<releases>
|
||||||
|
<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">
|
||||||
|
<description>
|
||||||
|
<p>Windows: the mouse works again; Windows Ink no longer reports it as a pen.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.39.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>First experimental Windows version as a portable program. Internal groundwork for Android; nothing changes on Linux.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.38.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>The notebooks can be stored in a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox. Changes from other devices are taken over automatically, conflict copies become pages of their own, and a warning appears when a notebook is open on another device.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.37.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.36.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.35.0" date="2026-10-27">
|
||||||
|
<description>
|
||||||
|
<p>PDF and PNG export and copying a selection as an image are about six times faster, and exported PDFs are much smaller, because stroke outlines are simplified invisibly before export.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.2" date="2026-10-26">
|
||||||
|
<description>
|
||||||
|
<p>The swipe speed for gliding is measured with the touch screen's own event times, so a swipe still glides when the app handles the lift a little late.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.1" date="2026-10-25">
|
||||||
|
<description>
|
||||||
|
<p>A finger swipe is measured reliably again for gliding, also when the view touches the edge of the notebook. The fling diagnostics log more details.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.34.0" date="2026-10-23">
|
||||||
|
<description>
|
||||||
|
<p>The eraser size is set in a menu like the pens, with presets and a slider. Corners of snapped shapes stay pointed. Slides get flat tiles in the page overview.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.33.0" date="2026-10-21">
|
<release version="0.33.0" date="2026-10-21">
|
||||||
<description>
|
<description>
|
||||||
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
<p>The hand is ignored while the pen is near the screen, also on the title bar. The eraser comes in four sizes. The page overview shows imported PDF pages. Snapped triangles and other shapes have the same width on every side. A selection made with the pen's select button can be moved with the pen.</p>
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
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:
|
||||||
|
- **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 (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
|
||||||
|
- **Import from Saber**: ☰ → Import (or dragging the file into the window) now also opens Saber notebooks (`.sbn2` with its asset files next to it, `.sba` archives, old `.sbn`) – strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds
|
||||||
|
- **Import from Xournal++ and Rnote**: ☰ → Import (or dragging the file into the window) now also opens Xournal++ (`.xopp`, `.xoj`) and Rnote (`.rnote`) notebooks as new notebooks – strokes with pen pressure, highlighters, shapes, texts, images, paper patterns and Xournal++ PDF backgrounds; PDF pages imported in Rnote become images, infinite Rnote documents an endless notebook
|
||||||
|
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
|
||||||
|
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
|
||||||
|
- **Fling fix**: A finger swipe is no longer lost when the view touches the edge of the notebook, so the page glides on after a fast swipe; the fling diagnostics (`INK_NOTES_DEBUG_FLING=1`) log moves, distance and timing
|
||||||
|
- **Feedback round**: The eraser size is set in a menu like the pens (four presets and a slider); corners of snapped triangles and polygons stay pointed; slides (landscape PDFs) get flat tiles in the page overview without an empty strip
|
||||||
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
|
- **Palm rejection & more feedback**: A hand resting on the screen while the pen is near is ignored (also on the title bar, no more accidental paste menu or window moving); the eraser has four sizes (button below the eraser modes, remembered); the page overview shows imported PDF pages; snapped triangles, rectangles and polygons have the same width on every side; a selection made with the pen's select button can be moved with the bare pen
|
||||||
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
|
- **Feedback round**: The endless canvas starts at the top left and only grows to the right and down; new text fields start short and grow with the text; toolbar buttons are larger again on small screens; on Wayland the title bar buttons (close, minimize, maximize) and moving the window work with a finger
|
||||||
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
|
- **View only**: 👁 in the toolbar hides the tools – pen, finger and mouse only scroll and zoom (the pen glides on like a finger), nothing can be changed by accident; tap 👁 again to write
|
||||||
|
|||||||
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, SHA256-Prüfsummen und deren Minisign-Signatur in target/
|
# Erstellt .deb, .AppImage, Windows-.exe, SHA256-Prüfsummen und deren Minisign-Signatur in target/
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ cd "${ROOT_DIR}"
|
|||||||
VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
|
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"
|
||||||
CHECKSUM_FILE="SHA256SUMS.txt"
|
CHECKSUM_FILE="SHA256SUMS.txt"
|
||||||
|
|
||||||
echo "======================================================================"
|
echo "======================================================================"
|
||||||
@@ -25,12 +26,15 @@ echo "======================================================================"
|
|||||||
# 2. AppImage bauen (.AppImage)
|
# 2. AppImage bauen (.AppImage)
|
||||||
"${SCRIPT_DIR}/package-appimage.sh"
|
"${SCRIPT_DIR}/package-appimage.sh"
|
||||||
|
|
||||||
# 3. Checksums berechnen
|
# 3. Portable Windows program (.exe)
|
||||||
|
"${SCRIPT_DIR}/package-windows.sh"
|
||||||
|
|
||||||
|
# 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}" > "${CHECKSUM_FILE}"
|
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" > "${CHECKSUM_FILE}"
|
||||||
|
|
||||||
# 4. 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
|
||||||
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
|
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
|
||||||
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
|
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
|
||||||
@@ -73,7 +77,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}" "${CHECKSUM_FILE}"
|
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${EXE_NAME}" "${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}"
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Builds the portable Windows program target/ink-notes_<version>_x86_64.exe (Issue #48)
|
||||||
|
# by cross-compiling on Linux. Needs the MinGW-w64 compiler
|
||||||
|
# (Debian/Ubuntu: gcc-mingw-w64-x86-64) for the C parts of the dependencies.
|
||||||
|
# ==============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
|
||||||
|
TARGET="x86_64-pc-windows-gnu"
|
||||||
|
VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2)
|
||||||
|
EXE_NAME="ink-notes_${VERSION}_x86_64.exe"
|
||||||
|
|
||||||
|
if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then
|
||||||
|
echo "FEHLER: x86_64-w64-mingw32-gcc nicht gefunden (Paket gcc-mingw-w64-x86-64)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if command -v rustup >/dev/null 2>&1; then
|
||||||
|
rustup target add "${TARGET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Baue ${EXE_NAME} ==="
|
||||||
|
cargo build --release --target "${TARGET}"
|
||||||
|
cp "${CARGO_TARGET_DIR:-target}/${TARGET}/release/ink-notes.exe" "target/${EXE_NAME}"
|
||||||
|
ls -lh "target/${EXE_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, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
|
# Lädt .deb, .AppImage, .exe, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -32,6 +32,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"
|
||||||
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"
|
||||||
@@ -42,7 +43,7 @@ if [ ! -f "${TEMPLATE_FILE}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Sicherstellen, dass die Artefakte existieren
|
# Sicherstellen, dass die Artefakte existieren
|
||||||
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
|
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${EXE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
|
||||||
echo "Artefakte nicht vollständig gefunden. Führe build-release.sh aus..."
|
echo "Artefakte nicht vollständig gefunden. Führe build-release.sh aus..."
|
||||||
"${SCRIPT_DIR}/build-release.sh"
|
"${SCRIPT_DIR}/build-release.sh"
|
||||||
fi
|
fi
|
||||||
@@ -236,6 +237,7 @@ except Exception:
|
|||||||
|
|
||||||
upload_asset "${DEB_FILE}"
|
upload_asset "${DEB_FILE}"
|
||||||
upload_asset "${APPIMAGE_FILE}"
|
upload_asset "${APPIMAGE_FILE}"
|
||||||
|
upload_asset "${EXE_FILE}"
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5603
File diff suppressed because it is too large.
Load diff
+3
-15
@@ -690,21 +690,9 @@ impl AutosaveController {
|
|||||||
|
|
||||||
/// Liefert das Verzeichnis für Notfall-Wiederherstellungsdateien ($XDG_DATA_HOME/ink-notes/recovery).
|
/// Liefert das Verzeichnis für Notfall-Wiederherstellungsdateien ($XDG_DATA_HOME/ink-notes/recovery).
|
||||||
pub fn recovery_dir() -> PathBuf {
|
pub fn recovery_dir() -> PathBuf {
|
||||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
crate::paths::data_dir()
|
||||||
if !xdg.is_empty() {
|
.map(|d| d.join("recovery"))
|
||||||
return PathBuf::from(xdg).join("ink-notes").join("recovery");
|
.unwrap_or_else(|| PathBuf::from(".").join(".recovery"))
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
|
||||||
if !home.is_empty() {
|
|
||||||
return PathBuf::from(home)
|
|
||||||
.join(".local")
|
|
||||||
.join("share")
|
|
||||||
.join("ink-notes")
|
|
||||||
.join("recovery");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PathBuf::from(".").join(".recovery")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for AutosaveController {
|
impl Drop for AutosaveController {
|
||||||
|
|||||||
+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()
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
//! Notices changes that another device made to the open notebook through a sync client
|
||||||
|
//! such as Nextcloud or Google Drive (Issue #49).
|
||||||
|
//!
|
||||||
|
//! Every few seconds the notebook folder is scanned (only file times and sizes). Pages whose
|
||||||
|
//! file changed are compared with the page in memory and reloaded if they differ; a changed
|
||||||
|
//! page order, new or removed pages and titles from the manifest are taken over; conflict
|
||||||
|
//! copies made by sync clients (`page_3 (conflicted copy …).json`, `page_3 (1).json`,
|
||||||
|
//! `page_3.sync-conflict-….json`) become pages of their own, so nothing is lost. The check
|
||||||
|
//! only runs while all local changes are saved, so it never overwrites anything unsaved.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
|
use crate::document::{Notebook, Page, PageBackground};
|
||||||
|
use crate::storage::{self, NotebookManifest, OpenMarker, PageData};
|
||||||
|
|
||||||
|
/// Time between two checks.
|
||||||
|
pub const INTERVAL: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct Outcome {
|
||||||
|
/// Pages reloaded because their file changed
|
||||||
|
pub reloaded_pages: usize,
|
||||||
|
/// Pages added, removed, reordered or retitled through the manifest
|
||||||
|
pub structure_changed: bool,
|
||||||
|
/// Ids of the conflict copies added as pages (they need saving)
|
||||||
|
pub conflict_pages: Vec<u64>,
|
||||||
|
/// Another device has just opened the notebook
|
||||||
|
pub opened_elsewhere: Option<OpenMarker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
*self == Outcome::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiskWatch {
|
||||||
|
pub dir: PathBuf,
|
||||||
|
known: HashMap<String, Fingerprint>,
|
||||||
|
/// Files that are neither pages nor conflict copies we could read
|
||||||
|
ignored: HashSet<String>,
|
||||||
|
next_check: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprints of the JSON files in the notebook folder.
|
||||||
|
fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
|
return HashMap::new();
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
if !name.ends_with(".json") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let meta = entry.metadata().ok()?;
|
||||||
|
if !meta.is_file() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hash = (meta.len() <= HASHED_FILE_SIZE)
|
||||||
|
.then(|| content_hash(&entry.path()))
|
||||||
|
.flatten();
|
||||||
|
Some((name, (meta.modified().ok(), meta.len(), hash)))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page id of a regular page file (`page_<id>.json`).
|
||||||
|
fn page_file_id(name: &str) -> Option<u64> {
|
||||||
|
name.strip_prefix("page_")?
|
||||||
|
.strip_suffix(".json")?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page id a conflict copy belongs to: `page_<id>` followed by something else.
|
||||||
|
pub fn conflict_copy_of(name: &str) -> Option<u64> {
|
||||||
|
if page_file_id(name).is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = name.strip_prefix("page_")?.strip_suffix(".json")?;
|
||||||
|
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||||
|
let tail = &rest[digits.len()..];
|
||||||
|
let marked =
|
||||||
|
tail.starts_with(" (") || tail.starts_with(".sync-conflict") || tail.starts_with('_');
|
||||||
|
(marked && !digits.is_empty())
|
||||||
|
.then(|| digits.parse().ok())
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page content without the format version (that differs between app versions).
|
||||||
|
fn content(mut data: PageData) -> PageData {
|
||||||
|
data.format_version = 0;
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskWatch {
|
||||||
|
pub fn new(dir: PathBuf, now: Instant) -> Self {
|
||||||
|
Self {
|
||||||
|
known: scan(&dir),
|
||||||
|
dir,
|
||||||
|
ignored: HashSet::new(),
|
||||||
|
next_check: now + INTERVAL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deadline(&self) -> Instant {
|
||||||
|
self.next_check
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_due(&self, now: Instant) -> bool {
|
||||||
|
now >= self.next_check
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skips the check that is due now (e.g. while drawing).
|
||||||
|
pub fn postpone(&mut self, now: Instant) {
|
||||||
|
self.next_check = now + INTERVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks right away at the next opportunity (e.g. when the window gets the focus).
|
||||||
|
pub fn check_soon(&mut self, now: Instant) {
|
||||||
|
self.next_check = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes over what changed on disk. Call only while all local changes are saved.
|
||||||
|
pub fn check(
|
||||||
|
&mut self,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
now: Instant,
|
||||||
|
) -> Outcome {
|
||||||
|
self.next_check = now + INTERVAL;
|
||||||
|
let mut outcome = Outcome::default();
|
||||||
|
let current = scan(&self.dir);
|
||||||
|
let changed = |name: &str| current.get(name) != self.known.get(name);
|
||||||
|
let mut retry: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
if changed(storage::OPEN_MARKER_FILE) {
|
||||||
|
outcome.opened_elsewhere = fs::read(self.dir.join(storage::OPEN_MARKER_FILE))
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok())
|
||||||
|
.filter(|m| m.device != storage::device_name());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page order, titles, new and removed pages
|
||||||
|
let mut fresh_pages: HashSet<u64> = HashSet::new();
|
||||||
|
if changed("manifest.json") {
|
||||||
|
match storage::load_manifest(&self.dir) {
|
||||||
|
Ok(theirs) => match self.apply_manifest(notebook, manifest, theirs) {
|
||||||
|
Ok(Some(new_ids)) => {
|
||||||
|
fresh_pages = new_ids;
|
||||||
|
outcome.structure_changed = true;
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(()) => {
|
||||||
|
retry.insert("manifest.json".to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
retry.insert("manifest.json".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed page files
|
||||||
|
for name in current.keys().filter(|n| changed(n)) {
|
||||||
|
let Some(id) = page_file_id(name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(page) = notebook.pages.iter_mut().find(|p| p.id == id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !page.is_loaded || fresh_pages.contains(&id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match storage::load_page(&self.dir, id) {
|
||||||
|
Ok(data) => {
|
||||||
|
if content(PageData::from(&*page)) != content(data.clone()) {
|
||||||
|
let mut reloaded = data.to_page();
|
||||||
|
reloaded.title = reloaded.title.or_else(|| page.title.clone());
|
||||||
|
*page = reloaded;
|
||||||
|
outcome.reloaded_pages += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Possibly still being written by the sync client: try again next time
|
||||||
|
Err(_) => {
|
||||||
|
retry.insert(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict copies of sync clients become pages of their own
|
||||||
|
let mut copies: Vec<&String> = current
|
||||||
|
.keys()
|
||||||
|
.filter(|n| !self.ignored.contains(*n))
|
||||||
|
.filter(|n| conflict_copy_of(n).is_some())
|
||||||
|
.collect();
|
||||||
|
copies.sort();
|
||||||
|
for name in copies {
|
||||||
|
let path = self.dir.join(name);
|
||||||
|
let data = fs::read(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<PageData>(&b).ok());
|
||||||
|
match data {
|
||||||
|
Some(data) => {
|
||||||
|
let of = conflict_copy_of(name).unwrap_or(0);
|
||||||
|
outcome
|
||||||
|
.conflict_pages
|
||||||
|
.push(add_conflict_page(notebook, data, of));
|
||||||
|
// Keep the file, but don't take it over again
|
||||||
|
let _ = fs::rename(&path, path.with_extension("json.imported"));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.ignored.insert(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !outcome.conflict_pages.is_empty() {
|
||||||
|
manifest.sync_from_notebook(notebook);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember what we have seen, except files to look at again
|
||||||
|
let mut known = current;
|
||||||
|
for name in retry {
|
||||||
|
match self.known.get(&name) {
|
||||||
|
Some(old) => known.insert(name, *old),
|
||||||
|
None => known.remove(&name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
self.known = known;
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes over the page list of a manifest written elsewhere. Returns the ids of pages
|
||||||
|
/// that are now unloaded placeholders, or `None` if nothing about the pages changed.
|
||||||
|
/// Fails (to be tried again) while the page to show can't be read yet.
|
||||||
|
fn apply_manifest(
|
||||||
|
&self,
|
||||||
|
notebook: &mut Notebook,
|
||||||
|
manifest: &mut NotebookManifest,
|
||||||
|
theirs: NotebookManifest,
|
||||||
|
) -> Result<Option<HashSet<u64>>, ()> {
|
||||||
|
let mut ours = manifest.clone();
|
||||||
|
ours.sync_from_notebook(notebook);
|
||||||
|
if ours.pages == theirs.pages && ours.title == theirs.title {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let current_id = notebook.current_page().id;
|
||||||
|
|
||||||
|
// The page shown afterwards must be loaded. Read it before changing anything: a file
|
||||||
|
// the sync client is still writing must not end up set aside as damaged.
|
||||||
|
let shown = theirs
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == current_id)
|
||||||
|
.or(theirs.pages.first())
|
||||||
|
.map(|m| m.id);
|
||||||
|
let mut shown_data = None;
|
||||||
|
if let Some(id) = shown {
|
||||||
|
if !notebook.pages.iter().any(|p| p.id == id && p.is_loaded) {
|
||||||
|
match storage::load_page(&self.dir, id) {
|
||||||
|
Ok(data) => shown_data = Some(data),
|
||||||
|
// Never saved because still empty
|
||||||
|
Err(storage::StorageError::PageNotFound(_)) => {}
|
||||||
|
Err(_) => return Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut old: HashMap<u64, Page> = notebook.pages.drain(..).map(|p| (p.id, p)).collect();
|
||||||
|
let mut placeholders = HashSet::new();
|
||||||
|
for meta in &theirs.pages {
|
||||||
|
let background = meta.background.unwrap_or(PageBackground::Grid5mm);
|
||||||
|
let size = storage::page_size_from(meta.size);
|
||||||
|
let mut page = match old.remove(&meta.id) {
|
||||||
|
Some(mut page) => {
|
||||||
|
page.background = background;
|
||||||
|
page.size = size;
|
||||||
|
page
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
placeholders.insert(meta.id);
|
||||||
|
Page::new_unloaded(meta.id, background).with_size(size)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
page.title = meta.title.clone();
|
||||||
|
notebook.pages.push(page);
|
||||||
|
}
|
||||||
|
if notebook.pages.is_empty() {
|
||||||
|
notebook.pages.push(Page::new(1, PageBackground::Grid5mm));
|
||||||
|
}
|
||||||
|
notebook.current_page_index = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.id == current_id)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(notebook.pages.len() - 1);
|
||||||
|
notebook.next_page_id = notebook.next_page_id.max(theirs.next_page_id);
|
||||||
|
notebook.next_stroke_id = notebook.next_stroke_id.max(theirs.next_stroke_id);
|
||||||
|
let index = notebook.current_page_index;
|
||||||
|
if !notebook.pages[index].is_loaded {
|
||||||
|
let page = &mut notebook.pages[index];
|
||||||
|
let mut loaded = match shown_data {
|
||||||
|
Some(data) => data.to_page(),
|
||||||
|
None => Page::new(page.id, page.background).with_size(page.size),
|
||||||
|
};
|
||||||
|
loaded.title = loaded.title.or(page.title.take());
|
||||||
|
*page = loaded;
|
||||||
|
placeholders.remove(&page.id);
|
||||||
|
}
|
||||||
|
let layout = manifest.layout;
|
||||||
|
*manifest = theirs;
|
||||||
|
manifest.layout = manifest.layout.or(layout);
|
||||||
|
manifest.sync_from_notebook(notebook);
|
||||||
|
Ok(Some(placeholders))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a conflict copy as a new page after page `of` (or at the end), with fresh ids.
|
||||||
|
/// Returns the id of the new page.
|
||||||
|
fn add_conflict_page(notebook: &mut Notebook, mut data: PageData, of: u64) -> u64 {
|
||||||
|
let id = notebook.next_page_id;
|
||||||
|
data.id = id;
|
||||||
|
notebook.next_page_id += 1;
|
||||||
|
for stroke in &mut data.strokes {
|
||||||
|
stroke.id = notebook.next_stroke_id;
|
||||||
|
notebook.next_stroke_id += 1;
|
||||||
|
}
|
||||||
|
let original = notebook.pages.iter().position(|p| p.id == of);
|
||||||
|
let title = original
|
||||||
|
.and_then(|i| notebook.pages[i].title.clone())
|
||||||
|
.or(data.title.take());
|
||||||
|
data.title = Some(match title {
|
||||||
|
Some(title) => crate::t!("Konfliktkopie: {title}", title = title).to_string(),
|
||||||
|
None => crate::t!("Konfliktkopie").to_string(),
|
||||||
|
});
|
||||||
|
let page = data.to_page();
|
||||||
|
let at = original.map_or(notebook.pages.len(), |i| i + 1);
|
||||||
|
notebook.pages.insert(at, page);
|
||||||
|
if at <= notebook.current_page_index {
|
||||||
|
notebook.current_page_index += 1;
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::{Stroke, StylusSample};
|
||||||
|
|
||||||
|
fn stroke(id: u64, y: f32) -> Stroke {
|
||||||
|
let points = (0..5)
|
||||||
|
.map(|i| StylusSample {
|
||||||
|
position: glam::Vec2::new(100.0 + 50.0 * i as f32, y),
|
||||||
|
pressure: 0.5,
|
||||||
|
timestamp: 0.0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Stroke::new(id, points, Vec::new(), [0.0, 0.0, 0.0, 1.0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saved notebook with one stroke on its only page.
|
||||||
|
fn saved_notebook(dir: &Path) -> (Notebook, NotebookManifest) {
|
||||||
|
let mut notebook = Notebook::new();
|
||||||
|
notebook.current_page_mut().add_stroke(stroke(1, 100.0));
|
||||||
|
notebook.next_stroke_id = 2;
|
||||||
|
let mut manifest = NotebookManifest::new("nb", "Mathe");
|
||||||
|
storage::save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
(notebook, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conflict_copy_names() {
|
||||||
|
assert_eq!(
|
||||||
|
conflict_copy_of("page_3 (conflicted copy 2026-10-27).json"),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert_eq!(conflict_copy_of("page_12 (1).json"), Some(12));
|
||||||
|
assert_eq!(
|
||||||
|
conflict_copy_of("page_3.sync-conflict-20261027-1200-ABC.json"),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
assert_eq!(conflict_copy_of("page_3_conflict.json"), Some(3));
|
||||||
|
assert_eq!(conflict_copy_of("page_3.json"), None);
|
||||||
|
assert_eq!(conflict_copy_of("page_x (1).json"), None);
|
||||||
|
assert_eq!(conflict_copy_of("manifest (1).json"), None);
|
||||||
|
assert_eq!(conflict_copy_of("page_3.json.imported"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_own_saves_are_not_reported() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||||
|
|
||||||
|
notebook.current_page_mut().add_stroke(stroke(2, 200.0));
|
||||||
|
notebook.add_page(PageBackground::Blank);
|
||||||
|
storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap();
|
||||||
|
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert!(outcome.is_empty(), "{outcome:?}");
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_changes_from_another_device_are_taken_over() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||||
|
assert!(!watch.is_due(now));
|
||||||
|
|
||||||
|
// The other device adds a stroke on page 1 and a second page with a title
|
||||||
|
let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap();
|
||||||
|
theirs.current_page_mut().add_stroke(stroke(5, 300.0));
|
||||||
|
let index = theirs.add_page(PageBackground::Lined);
|
||||||
|
theirs.pages[index].add_stroke(stroke(6, 400.0));
|
||||||
|
theirs.pages[index].title = Some("Übung".to_string());
|
||||||
|
theirs.next_stroke_id = 7;
|
||||||
|
storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap();
|
||||||
|
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert_eq!(outcome.reloaded_pages, 1);
|
||||||
|
assert!(outcome.structure_changed);
|
||||||
|
assert!(outcome.conflict_pages.is_empty());
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 2);
|
||||||
|
assert_eq!(notebook.pages[1].title.as_deref(), Some("Übung"));
|
||||||
|
assert_eq!(notebook.pages[1].background, PageBackground::Lined);
|
||||||
|
assert!(notebook.next_stroke_id >= 7);
|
||||||
|
assert_eq!(manifest.pages.len(), 2);
|
||||||
|
|
||||||
|
// The new page loads from disk when needed
|
||||||
|
storage::ensure_page_loaded(dir.path(), &mut notebook, 1).unwrap();
|
||||||
|
assert_eq!(notebook.pages[1].strokes.len(), 1);
|
||||||
|
|
||||||
|
// Nothing new: nothing to report
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert!(outcome.is_empty(), "{outcome:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_still_being_synced_is_tried_again() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||||
|
|
||||||
|
// Elsewhere page 1 was deleted and page 2 added; its file is still half written
|
||||||
|
let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap();
|
||||||
|
let index = theirs.add_page(PageBackground::Blank);
|
||||||
|
theirs.pages[index].add_stroke(stroke(9, 100.0));
|
||||||
|
storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap();
|
||||||
|
their_manifest.pages.remove(0);
|
||||||
|
storage::save_manifest(dir.path(), &their_manifest).unwrap();
|
||||||
|
let page_file = dir.path().join("page_2.json");
|
||||||
|
let complete = fs::read(&page_file).unwrap();
|
||||||
|
fs::write(&page_file, &complete[..complete.len() / 2]).unwrap();
|
||||||
|
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert!(outcome.is_empty(), "{outcome:?}");
|
||||||
|
assert_eq!(notebook.pages.len(), 1);
|
||||||
|
assert_eq!(notebook.pages[0].id, 1);
|
||||||
|
assert!(page_file.exists(), "must not be set aside as damaged");
|
||||||
|
|
||||||
|
fs::write(&page_file, &complete).unwrap();
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert!(outcome.structure_changed);
|
||||||
|
assert_eq!(notebook.pages.len(), 1);
|
||||||
|
assert_eq!(notebook.current_page().id, 2);
|
||||||
|
assert!(notebook.current_page().is_loaded);
|
||||||
|
assert_eq!(notebook.current_page().strokes.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conflict_copy_becomes_a_page() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||||
|
notebook.add_page(PageBackground::Blank);
|
||||||
|
notebook.current_page_index = 1;
|
||||||
|
storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||||
|
|
||||||
|
let mut copy = PageData::from(¬ebook.pages[0]);
|
||||||
|
copy.strokes.push(
|
||||||
|
PageData::from(&{
|
||||||
|
let mut page = Page::new(1, PageBackground::Blank);
|
||||||
|
page.add_stroke(stroke(1, 500.0));
|
||||||
|
page
|
||||||
|
})
|
||||||
|
.strokes[0]
|
||||||
|
.clone(),
|
||||||
|
);
|
||||||
|
let name = "page_1 (conflicted copy 2026-10-27).json";
|
||||||
|
fs::write(dir.path().join(name), serde_json::to_vec(©).unwrap()).unwrap();
|
||||||
|
fs::write(dir.path().join("page_2 (1).json"), b"{ kaputt").unwrap();
|
||||||
|
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert_eq!(outcome.conflict_pages.len(), 1);
|
||||||
|
assert_eq!(notebook.pages.len(), 3);
|
||||||
|
let added = ¬ebook.pages[1];
|
||||||
|
assert_eq!(added.id, outcome.conflict_pages[0]);
|
||||||
|
assert_eq!(added.strokes.len(), 2);
|
||||||
|
assert!(added.title.is_some());
|
||||||
|
// Fresh ids, no clash with the original page
|
||||||
|
assert!(added.strokes.iter().all(|s| s.id >= 2));
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 1);
|
||||||
|
// The current page stays the same
|
||||||
|
assert_eq!(notebook.current_page_index, 2);
|
||||||
|
assert_eq!(manifest.pages.len(), 3);
|
||||||
|
assert!(!dir.path().join(name).exists());
|
||||||
|
assert!(dir.path().join(format!("{name}.imported")).exists());
|
||||||
|
|
||||||
|
// Taken over only once; the unreadable file is left alone
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert!(outcome.is_empty(), "{outcome:?}");
|
||||||
|
assert!(dir.path().join("page_2 (1).json").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_marker() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||||
|
let marker_path = dir.path().join(storage::OPEN_MARKER_FILE);
|
||||||
|
let foreign = OpenMarker {
|
||||||
|
device: "tablet-of-pia".to_string(),
|
||||||
|
since: 1,
|
||||||
|
};
|
||||||
|
fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap();
|
||||||
|
|
||||||
|
// Opening warns about the other device and puts our own marker in place
|
||||||
|
let lock = storage::NotebookLock::acquire(dir.path()).unwrap();
|
||||||
|
assert_eq!(lock.open_elsewhere.as_ref(), Some(&foreign));
|
||||||
|
let ours: OpenMarker = serde_json::from_slice(&fs::read(&marker_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(ours.device, storage::device_name());
|
||||||
|
|
||||||
|
// The other device opens it again
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||||
|
fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap();
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert_eq!(outcome.opened_elsewhere, Some(foreign));
|
||||||
|
|
||||||
|
// A foreign marker is not removed on closing
|
||||||
|
drop(lock);
|
||||||
|
assert!(marker_path.exists());
|
||||||
|
let lock = storage::NotebookLock::acquire(dir.path()).unwrap();
|
||||||
|
drop(lock);
|
||||||
|
assert!(!marker_path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-142
@@ -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,15 +51,20 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Some(home) = crate::paths::home_dir() {
|
||||||
if !home.trim().is_empty() {
|
|
||||||
let home = PathBuf::from(home);
|
|
||||||
let user_dirs =
|
let user_dirs =
|
||||||
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||||
if let Some(dir) =
|
if let Some(dir) =
|
||||||
@@ -69,7 +75,6 @@ pub fn default_documents_dir() -> PathBuf {
|
|||||||
}
|
}
|
||||||
return home.join("Documents");
|
return home.join("Documents");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
PathBuf::from(".")
|
PathBuf::from(".")
|
||||||
}
|
}
|
||||||
@@ -150,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,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);
|
||||||
@@ -240,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))
|
||||||
@@ -252,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> {
|
||||||
@@ -288,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
|
||||||
@@ -308,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")?;
|
||||||
@@ -331,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 {
|
||||||
@@ -352,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
|
||||||
@@ -390,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(
|
||||||
@@ -450,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);
|
||||||
@@ -473,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,
|
||||||
) {
|
) {
|
||||||
@@ -484,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);
|
||||||
|
|
||||||
@@ -570,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;
|
||||||
@@ -581,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 {
|
||||||
@@ -651,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)?;
|
||||||
@@ -668,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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,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>,
|
||||||
) {
|
) {
|
||||||
@@ -728,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
|
||||||
@@ -747,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 {
|
||||||
@@ -766,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],
|
||||||
@@ -779,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();
|
||||||
@@ -799,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,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>,
|
||||||
) {
|
) {
|
||||||
@@ -885,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 } => {
|
||||||
@@ -1181,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;
|
||||||
}
|
}
|
||||||
@@ -1222,21 +1171,25 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
|
|
||||||
// Geschlossener Pfad um den Ribbon-Rand:
|
// Geschlossener Pfad um den Ribbon-Rand:
|
||||||
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
// Left[0] -> Left[1] -> ... -> Left[n-1] -> Right[n-1] -> Right[n-2] -> ... -> Right[0]
|
||||||
let mut ring = Vec::with_capacity(n * 2);
|
let outline: Vec<glam::Vec2> = (0..n)
|
||||||
|
.map(|i| stroke.vertices[i * 2].position)
|
||||||
// 1. Linke Hüllkurve vorwärts
|
.chain((0..n).rev().map(|i| stroke.vertices[i * 2 + 1].position))
|
||||||
for i in 0..n {
|
.map(glam::Vec2::from)
|
||||||
let v = stroke.vertices[i * 2];
|
.collect();
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
// The mesh has about 16 points per pen sample, far more than the eye (or a printer) can
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
// tell apart; fewer points make the PDF, PNG export and copying as an image much faster
|
||||||
}
|
let outline = if exact_outlines() {
|
||||||
|
outline
|
||||||
// 2. Rechte Hüllkurve rückwärts
|
} else {
|
||||||
for i in (0..n).rev() {
|
crate::geometry::simplify_closed_outline(&outline, OUTLINE_TOLERANCE)
|
||||||
let v = stroke.vertices[i * 2 + 1];
|
};
|
||||||
let (x, y) = page_to_pdf_mm(v.position[0], v.position[1]);
|
let ring: Vec<(Point, bool)> = outline
|
||||||
ring.push((Point::new(Mm(x), Mm(y)), false));
|
.iter()
|
||||||
}
|
.map(|p| {
|
||||||
|
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||||
|
(Point::new(Mm(x), Mm(y)), false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let fill_color = Color::Rgb(Rgb::new(
|
let fill_color = Color::Rgb(Rgb::new(
|
||||||
stroke.color[0],
|
stroke.color[0],
|
||||||
@@ -1254,8 +1207,19 @@ fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
|||||||
layer.add_polygon(poly);
|
layer.add_polygon(poly);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
|
/// Largest deviation of an exported stroke outline from the drawn one, in page units
|
||||||
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
/// (0.01 mm: a tenth of a pixel at 200 dpi).
|
||||||
|
pub const OUTLINE_TOLERANCE: f32 = 0.1;
|
||||||
|
|
||||||
|
/// `INK_NOTES_EXACT_OUTLINES=1` exports every point of the stroke outlines, as before this change
|
||||||
|
/// (fallback in case the simplified outlines ever look wrong).
|
||||||
|
fn exact_outlines() -> bool {
|
||||||
|
static EXACT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*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 crate::pdf_writer::Line.
|
||||||
|
fn render_stroke_bezier(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) {
|
||||||
if stroke.points.is_empty() {
|
if stroke.points.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1267,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 =
|
||||||
@@ -1741,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"
|
||||||
@@ -1852,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");
|
||||||
|
|
||||||
@@ -1863,8 +1827,7 @@ 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]]))
|
||||||
@@ -1872,8 +1835,7 @@ mod tests {
|
|||||||
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();
|
||||||
|
|||||||
+51
-7
@@ -1,4 +1,4 @@
|
|||||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
|
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote, Saber).
|
||||||
//!
|
//!
|
||||||
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||||
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||||
@@ -22,8 +22,10 @@ pub struct PickerEntry {
|
|||||||
pub enum PickerKind {
|
pub enum PickerKind {
|
||||||
/// Bilder zum Einfügen auf der aktuellen Seite
|
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||||
Image,
|
Image,
|
||||||
/// PDF zum Importieren als neues Notizbuch
|
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
|
||||||
Pdf,
|
Import,
|
||||||
|
/// Folder for the notebooks (Issue #49): lists folders only
|
||||||
|
Folder,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PickerKind {
|
impl PickerKind {
|
||||||
@@ -31,7 +33,8 @@ impl PickerKind {
|
|||||||
pub fn accepts(self, path: &Path) -> bool {
|
pub fn accepts(self, path: &Path) -> bool {
|
||||||
match self {
|
match self {
|
||||||
PickerKind::Image => is_supported_image_path(path),
|
PickerKind::Image => is_supported_image_path(path),
|
||||||
PickerKind::Pdf => is_pdf_path(path),
|
PickerKind::Import => crate::import::is_importable_path(path),
|
||||||
|
PickerKind::Folder => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,8 @@ impl FilePicker {
|
|||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
let preferred = match kind {
|
let preferred = match kind {
|
||||||
PickerKind::Image => tn!("🖼 Bilder"),
|
PickerKind::Image => tn!("🖼 Bilder"),
|
||||||
PickerKind::Pdf => tn!("⬇ Downloads"),
|
PickerKind::Import => tn!("⬇ Downloads"),
|
||||||
|
PickerKind::Folder => tn!("🏠 Persönlicher Ordner"),
|
||||||
};
|
};
|
||||||
let all = shortcuts();
|
let all = shortcuts();
|
||||||
all.iter()
|
all.iter()
|
||||||
@@ -136,7 +140,7 @@ pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry
|
|||||||
/// mit den Ordnern aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“). Die
|
/// mit den Ordnern aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“). Die
|
||||||
/// Bezeichnungen sind deutsch, angezeigt werden sie mit `i18n::tr`.
|
/// Bezeichnungen sind deutsch, angezeigt werden sie mit `i18n::tr`.
|
||||||
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
let Some(home) = crate::paths::home_dir() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let user_dirs =
|
let user_dirs =
|
||||||
@@ -158,6 +162,30 @@ pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
|||||||
list.push((tn!("🖥 Schreibtisch"), p));
|
list.push((tn!("🖥 Schreibtisch"), p));
|
||||||
}
|
}
|
||||||
list.push((tn!("🏠 Persönlicher Ordner"), home));
|
list.push((tn!("🏠 Persönlicher Ordner"), home));
|
||||||
|
// Drives (Windows has no single root)
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
const DRIVES: [&str; 24] = [
|
||||||
|
"💽 C:", "💽 D:", "💽 E:", "💽 F:", "💽 G:", "💽 H:", "💽 I:", "💽 J:", "💽 K:",
|
||||||
|
"💽 L:", "💽 M:", "💽 N:", "💽 O:", "💽 P:", "💽 Q:", "💽 R:", "💽 S:", "💽 T:",
|
||||||
|
"💽 U:", "💽 V:", "💽 W:", "💽 X:", "💽 Y:", "💽 Z:",
|
||||||
|
];
|
||||||
|
// Looked up once: a disconnected network drive can take seconds to answer
|
||||||
|
static FOUND: std::sync::OnceLock<Vec<(&str, PathBuf)>> = std::sync::OnceLock::new();
|
||||||
|
let found = FOUND.get_or_init(|| {
|
||||||
|
DRIVES
|
||||||
|
.iter()
|
||||||
|
.map(|label| {
|
||||||
|
(
|
||||||
|
*label,
|
||||||
|
PathBuf::from(format!("{}\\", &label[label.len() - 2..])),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.filter(|(_, root)| root.is_dir())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
list.extend(found.iter().cloned());
|
||||||
|
}
|
||||||
list
|
list
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +220,10 @@ mod tests {
|
|||||||
".versteckt.png",
|
".versteckt.png",
|
||||||
"c.webp",
|
"c.webp",
|
||||||
"Skript.PDF",
|
"Skript.PDF",
|
||||||
|
"mathe.xopp",
|
||||||
|
"skizze.rnote",
|
||||||
|
"Uni.sbn2",
|
||||||
|
"Uni.sbn2.0",
|
||||||
] {
|
] {
|
||||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||||
}
|
}
|
||||||
@@ -210,7 +242,18 @@ mod tests {
|
|||||||
names(PickerKind::Image),
|
names(PickerKind::Image),
|
||||||
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
||||||
);
|
);
|
||||||
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
|
assert_eq!(
|
||||||
|
names(PickerKind::Import),
|
||||||
|
[
|
||||||
|
"aOrdner",
|
||||||
|
"zOrdner",
|
||||||
|
"mathe.xopp",
|
||||||
|
"skizze.rnote",
|
||||||
|
"Skript.PDF",
|
||||||
|
"Uni.sbn2"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(names(PickerKind::Folder), ["aOrdner", "zOrdner"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -235,6 +278,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
fn test_parse_user_dirs() {
|
fn test_parse_user_dirs() {
|
||||||
let home = Path::new("/home/anna");
|
let home = Path::new("/home/anna");
|
||||||
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
|
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
|
||||||
|
|||||||
+132
-2
@@ -352,6 +352,63 @@ fn rdp_recursive(points: &[Vec2], epsilon: f32) -> Vec<Vec2> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simplifies a closed outline (the last point connects back to the first) so that no point
|
||||||
|
/// of the original lies farther than `tolerance` from the result. Unlike
|
||||||
|
/// [`ramer_douglas_peucker`], it measures the distance to the segment, not to the infinite
|
||||||
|
/// line, so stroke tips that turn back are kept. Works without recursion, so long strokes
|
||||||
|
/// cannot overflow the stack.
|
||||||
|
pub fn simplify_closed_outline(points: &[Vec2], tolerance: f32) -> Vec<Vec2> {
|
||||||
|
if points.len() <= 3 {
|
||||||
|
return points.to_vec();
|
||||||
|
}
|
||||||
|
// Two chains between the first point and the point farthest from it: the start of an
|
||||||
|
// outline often lies on top of its end (round cap), which would leave no chord to measure
|
||||||
|
let far = (1..points.len())
|
||||||
|
.max_by(|&i, &j| {
|
||||||
|
points[i]
|
||||||
|
.distance_squared(points[0])
|
||||||
|
.total_cmp(&points[j].distance_squared(points[0]))
|
||||||
|
})
|
||||||
|
.unwrap_or(1);
|
||||||
|
let mut keep = vec![false; points.len()];
|
||||||
|
keep[0] = true;
|
||||||
|
keep[far] = true;
|
||||||
|
let mut stack = vec![(0, far), (far, points.len())];
|
||||||
|
while let Some((start, end)) = stack.pop() {
|
||||||
|
// `end == points.len()` closes the ring back to the first point
|
||||||
|
let (a, b) = (points[start], points[end % points.len()]);
|
||||||
|
let mut worst = (0.0_f32, start);
|
||||||
|
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
|
||||||
|
let d = point_to_segment_dist(p, a, b);
|
||||||
|
if d > worst.0 {
|
||||||
|
worst = (d, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worst.0 > tolerance {
|
||||||
|
keep[worst.1] = true;
|
||||||
|
stack.push((start, worst.1));
|
||||||
|
stack.push((worst.1, end));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points
|
||||||
|
.iter()
|
||||||
|
.zip(keep)
|
||||||
|
.filter_map(|(&p, keep)| keep.then_some(p))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstand eines Punktes `p` zur Strecke von `a` nach `b`.
|
||||||
|
#[inline]
|
||||||
|
pub fn point_to_segment_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
|
let ab = b - a;
|
||||||
|
let len_sq = ab.length_squared();
|
||||||
|
if len_sq < 1e-12 {
|
||||||
|
return p.distance(a);
|
||||||
|
}
|
||||||
|
let t = ((p - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||||
|
p.distance(a + ab * t)
|
||||||
|
}
|
||||||
|
|
||||||
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
/// Abstand eines Punktes `p` zur Geraden durch `a` und `b`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
pub fn point_to_line_dist(p: Vec2, a: Vec2, b: Vec2) -> f32 {
|
||||||
@@ -1801,8 +1858,9 @@ pub fn contour_rms(points: &[Vec2], shape: &RecognizedShape) -> Option<f32> {
|
|||||||
Some((sum_sq / points.len() as f32).sqrt())
|
Some((sum_sq / points.len() as f32).sqrt())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Longest miter of a polygon corner, in half stroke widths; sharper corners are bevelled.
|
/// Longest miter of a polygon corner, in half stroke widths: corners down to about 14° stay
|
||||||
const MITER_LIMIT: f32 = 2.0;
|
/// pointed, only sharper ones are bevelled (Issue #41).
|
||||||
|
const MITER_LIMIT: f32 = 8.0;
|
||||||
|
|
||||||
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
|
/// Vertex pairs (left, right of the outline direction) of a triangle strip at corner `curr`
|
||||||
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
|
/// between the edges from `prev` and to `next` with left normals `n_prev` and `n_next`. Both
|
||||||
@@ -2326,6 +2384,78 @@ pub fn shape_to_stroke(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Largest distance of any point of `original` from the closed outline `simplified`.
|
||||||
|
fn outline_deviation(original: &[Vec2], simplified: &[Vec2]) -> f32 {
|
||||||
|
original
|
||||||
|
.iter()
|
||||||
|
.map(|&p| {
|
||||||
|
(0..simplified.len())
|
||||||
|
.map(|i| {
|
||||||
|
point_to_segment_dist(
|
||||||
|
p,
|
||||||
|
simplified[i],
|
||||||
|
simplified[(i + 1) % simplified.len()],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.fold(f32::INFINITY, f32::min)
|
||||||
|
})
|
||||||
|
.fold(0.0, f32::max)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simplified_stroke_outline_stays_within_tolerance() {
|
||||||
|
// A handwritten loop like a letter, as the pen delivers it
|
||||||
|
let samples: Vec<StylusSample> = (0..30)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f32 / 29.0 * std::f32::consts::TAU * 1.3;
|
||||||
|
StylusSample {
|
||||||
|
position: Vec2::new(500.0 + 12.0 * t.cos() + t * 4.0, 800.0 + 20.0 * t.sin()),
|
||||||
|
pressure: 0.4 + 0.3 * (t * 0.7).sin().abs(),
|
||||||
|
timestamp: i as f64 * 0.008,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let vertices = crate::storage::reconstruct_stroke_vertices(
|
||||||
|
&samples,
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
5.0,
|
||||||
|
Default::default(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let n = vertices.len() / 2;
|
||||||
|
let outline: Vec<Vec2> = (0..n)
|
||||||
|
.map(|i| vertices[i * 2].position)
|
||||||
|
.chain((0..n).rev().map(|i| vertices[i * 2 + 1].position))
|
||||||
|
.map(Vec2::from)
|
||||||
|
.collect();
|
||||||
|
let tolerance = 0.1;
|
||||||
|
let simplified = simplify_closed_outline(&outline, tolerance);
|
||||||
|
let deviation = outline_deviation(&outline, &simplified);
|
||||||
|
assert!(deviation <= tolerance + 1e-4, "{deviation}");
|
||||||
|
assert!(
|
||||||
|
simplified.len() * 4 < outline.len(),
|
||||||
|
"{} -> {} points",
|
||||||
|
outline.len(),
|
||||||
|
simplified.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_simplified_outline_keeps_tips_that_turn_back() {
|
||||||
|
// Thin hairpin: out along y = 0 to x = 20, back along y = 0.05. Measured against the
|
||||||
|
// infinite line, the tip would vanish.
|
||||||
|
let mut outline: Vec<Vec2> = (0..=20).map(|x| Vec2::new(x as f32, 0.0)).collect();
|
||||||
|
outline.extend((0..20).rev().map(|x| Vec2::new(x as f32, 0.05)));
|
||||||
|
let simplified = simplify_closed_outline(&outline, 0.1);
|
||||||
|
let reach = simplified.iter().map(|p| p.x).fold(f32::MIN, f32::max);
|
||||||
|
assert_eq!(reach, 20.0, "{simplified:?}");
|
||||||
|
assert!(outline_deviation(&outline, &simplified) <= 0.1);
|
||||||
|
assert!(simplified.len() <= 4, "{simplified:?}");
|
||||||
|
// Tiny outlines stay as they are
|
||||||
|
let dot = [Vec2::ZERO, Vec2::X, Vec2::Y];
|
||||||
|
assert_eq!(simplify_closed_outline(&dot, 0.1), dot.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_line() {
|
fn test_detect_line() {
|
||||||
// Leicht zittrige diagonale Punktfolge
|
// Leicht zittrige diagonale Punktfolge
|
||||||
|
|||||||
+6
-1
@@ -151,12 +151,13 @@ mod tests {
|
|||||||
("assets.rs", include_str!("assets.rs")),
|
("assets.rs", include_str!("assets.rs")),
|
||||||
("autosave.rs", include_str!("autosave.rs")),
|
("autosave.rs", include_str!("autosave.rs")),
|
||||||
("background.rs", include_str!("background.rs")),
|
("background.rs", include_str!("background.rs")),
|
||||||
|
("disk_watch.rs", include_str!("disk_watch.rs")),
|
||||||
("document.rs", include_str!("document.rs")),
|
("document.rs", include_str!("document.rs")),
|
||||||
("export.rs", include_str!("export.rs")),
|
("export.rs", include_str!("export.rs")),
|
||||||
("file_picker.rs", include_str!("file_picker.rs")),
|
("file_picker.rs", include_str!("file_picker.rs")),
|
||||||
("geometry.rs", include_str!("geometry.rs")),
|
("geometry.rs", include_str!("geometry.rs")),
|
||||||
("library.rs", include_str!("library.rs")),
|
("library.rs", include_str!("library.rs")),
|
||||||
("main.rs", include_str!("main.rs")),
|
("app.rs", include_str!("app.rs")),
|
||||||
("page_export.rs", include_str!("page_export.rs")),
|
("page_export.rs", include_str!("page_export.rs")),
|
||||||
("pdf_doc.rs", include_str!("pdf_doc.rs")),
|
("pdf_doc.rs", include_str!("pdf_doc.rs")),
|
||||||
("pdf_merge.rs", include_str!("pdf_merge.rs")),
|
("pdf_merge.rs", include_str!("pdf_merge.rs")),
|
||||||
@@ -167,6 +168,10 @@ mod tests {
|
|||||||
("text.rs", include_str!("text.rs")),
|
("text.rs", include_str!("text.rs")),
|
||||||
("ui.rs", include_str!("ui.rs")),
|
("ui.rs", include_str!("ui.rs")),
|
||||||
("updater.rs", include_str!("updater.rs")),
|
("updater.rs", include_str!("updater.rs")),
|
||||||
|
("import.rs", include_str!("import.rs")),
|
||||||
|
("rnote.rs", include_str!("rnote.rs")),
|
||||||
|
("saber.rs", include_str!("saber.rs")),
|
||||||
|
("xopp.rs", include_str!("xopp.rs")),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
/// Alle Texte in `t!("…")` und `tn!("…")` eines Quelltexts, auch über mehrere Zeilen
|
||||||
|
|||||||
+47
-9
@@ -52,7 +52,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
("Das PDF enthält keine Seiten", "The PDF has no pages"),
|
||||||
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
|
("Das PDF hat {count} Seiten, höchstens {max} werden unterstützt", "The PDF has {count} pages, at most {max} are supported"),
|
||||||
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
|
("Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden", "The PDF is encrypted or password protected and cannot be opened"),
|
||||||
("Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. Beim Export liegen deine Notizen über den Originalseiten.", "The PDF becomes a new notebook, one page per PDF page. When exporting, your notes lie on top of the original pages."),
|
|
||||||
("Datei existiert nicht", "File does not exist"),
|
("Datei existiert nicht", "File does not exist"),
|
||||||
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
("Den Ordner gibt es nicht mehr", "The folder no longer exists"),
|
||||||
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
("Den Ordner „{name}“ gibt es hier schon", "A folder “{name}” already exists here"),
|
||||||
@@ -120,6 +119,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
|
("Gitea Release-JSON konnte nicht verarbeitet werden: {e}", "Could not process the Gitea release JSON: {e}"),
|
||||||
("Gitea Server-URL:", "Gitea server URL:"),
|
("Gitea Server-URL:", "Gitea server URL:"),
|
||||||
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
|
("Gitea-Server antwortete mit HTTP {code}: {body}", "Gitea server responded with HTTP {code}: {body}"),
|
||||||
|
("GRÖSSE", "SIZE"),
|
||||||
("Graphit-Grau", "Graphite gray"),
|
("Graphit-Grau", "Graphite gray"),
|
||||||
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
|
("Größere Schrift (jetzt {size} pt)", "Larger font (now {size} pt)"),
|
||||||
("Grün", "Green"),
|
("Grün", "Green"),
|
||||||
@@ -149,7 +149,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Keine manifest.json vorhanden", "No manifest.json present"),
|
("Keine manifest.json vorhanden", "No manifest.json present"),
|
||||||
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
|
("Keine nutzbare Grafikschnittstelle gefunden.\nHäufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\nPrüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\nund neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\noder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.", "No usable graphics interface found.\nMost common cause: the user may not access the graphics card (/dev/dri).\nCheck with `ls -l /dev/dri` and `groups`; if needed `sudo usermod -aG render,video $USER`\nand log in again. Alternatively install a software renderer (package mesa-vulkan-drivers)\nor choose the interface, e.g. `WGPU_BACKEND=vulkan ink-notes`."),
|
||||||
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
|
("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)", "No folders or images (PNG, JPEG, GIF, WebP, BMP)"),
|
||||||
("Keine Ordner oder PDF-Dateien", "No folders or PDF files"),
|
|
||||||
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
("Keine Sicherungskopie (manifest.json.bak) vorhanden", "No backup copy (manifest.json.bak) present"),
|
||||||
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
("Kleinere Schrift (jetzt {size} pt)", "Smaller font (now {size} pt)"),
|
||||||
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
|
("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.", "Click 'Check for updates' to query the latest releases from the Gitea server."),
|
||||||
@@ -233,9 +232,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
("Papierkorb-Pfad '{path}' liegt nicht im Papierkorb", "Trash path '{path}' is not in the trash"),
|
||||||
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
("Papierkorb-Verzeichnis existiert nicht", "Trash folder does not exist"),
|
||||||
("Pastell-Cyan", "Pastel cyan"),
|
("Pastell-Cyan", "Pastel cyan"),
|
||||||
("PDF importieren", "Import PDF"),
|
|
||||||
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
|
("PDF kann nicht geöffnet werden", "Cannot open PDF"),
|
||||||
("PDF nicht importiert: {err}", "PDF not imported: {err}"),
|
|
||||||
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
|
("PDF nicht lesbar: {e}", "PDF not readable: {e}"),
|
||||||
("PDF ohne Seite", "PDF without a page"),
|
("PDF ohne Seite", "PDF without a page"),
|
||||||
("PDF-Export läuft…", "PDF export running…"),
|
("PDF-Export läuft…", "PDF export running…"),
|
||||||
@@ -256,9 +253,10 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
|
("Quell-PDF ist verschlüsselt", "Source PDF is encrypted"),
|
||||||
("Radieren", "Erase"),
|
("Radieren", "Erase"),
|
||||||
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
|
("Radierer (E oder rechte Maustaste) – darunter: ganze Striche, Teilstrich, nur Textmarker und die Größe", "Eraser (E or right mouse button) – below: whole strokes, partial stroke, highlighter only and the size"),
|
||||||
|
("Radierer", "Eraser"),
|
||||||
("Radierer (Eraser)", "Eraser"),
|
("Radierer (Eraser)", "Eraser"),
|
||||||
("Radiergröße {size} mm", "Eraser size {size} mm"),
|
("Radiergröße {size} mm – tippen zum Ändern", "Eraser size {size} mm – tap to change"),
|
||||||
("Radiergröße {size} mm – tippen für die nächste Größe", "Eraser size {size} mm – tap for the next size"),
|
("Radiergröße {size} setzen", "Set eraser size to {size}"),
|
||||||
("Raute (Rhombus)", "Rhombus"),
|
("Raute (Rhombus)", "Rhombus"),
|
||||||
("Rechteck", "Rectangle"),
|
("Rechteck", "Rectangle"),
|
||||||
("Rechts", "Right"),
|
("Rechts", "Right"),
|
||||||
@@ -314,6 +312,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
|
("Stift {number} ({width} mm)", "Pen {number} ({width} mm)"),
|
||||||
("Stift {number}", "Pen {number}"),
|
("Stift {number}", "Pen {number}"),
|
||||||
("Stift", "Pen"),
|
("Stift", "Pen"),
|
||||||
|
("STUFENLOSE GRÖSSE", "STEPLESS SIZE"),
|
||||||
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
|
("STUFENLOSE STÄRKE", "STEPLESS WIDTH"),
|
||||||
("Stärke auf {width} setzen", "Set width to {width}"),
|
("Stärke auf {width} setzen", "Set width to {width}"),
|
||||||
("System", "System"),
|
("System", "System"),
|
||||||
@@ -365,7 +364,6 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
("Verzeichnis '{path}' enthält kein manifest.json", "Folder '{path}' contains no manifest.json"),
|
||||||
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
("Verzeichnis '{path}' existiert nicht", "Folder '{path}' does not exist"),
|
||||||
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
("Vorherige Seite (Bild auf)", "Previous page (Page Up)"),
|
||||||
("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets or forms as a new notebook and write on them"),
|
|
||||||
("weich", "soft"),
|
("weich", "soft"),
|
||||||
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
("Weich: schon leichter Druck ergibt kräftige Linien.\nHart: erst fester Druck ergibt kräftige Linien.", "Soft: even light pressure gives bold lines.\nHard: only firm pressure gives bold lines."),
|
||||||
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
("Weitere Funktionen – Update verfügbar", "More – update available"),
|
||||||
@@ -427,9 +425,7 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
("📂 Im Dateimanager anzeigen", "📂 Show in file manager"),
|
||||||
("📂 Ordner öffnen", "📂 Open folder"),
|
("📂 Ordner öffnen", "📂 Open folder"),
|
||||||
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
("📄 Als PDF exportieren (Strg+E)", "📄 Export as PDF (Ctrl+E)"),
|
||||||
("📄 PDF importieren …", "📄 Import PDF …"),
|
|
||||||
("📄 Feste Seiten", "📄 Fixed pages"),
|
("📄 Feste Seiten", "📄 Fixed pages"),
|
||||||
("📄 PDF importieren", "📄 Import PDF"),
|
|
||||||
("📋 Einfügen", "📋 Paste"),
|
("📋 Einfügen", "📋 Paste"),
|
||||||
("📑 Inhalt", "📑 Contents"),
|
("📑 Inhalt", "📑 Contents"),
|
||||||
("📚 Alle", "📚 All"),
|
("📚 Alle", "📚 All"),
|
||||||
@@ -451,4 +447,46 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
("🚀 Update {version} verfügbar", "🚀 Update {version} available"),
|
||||||
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
("🚀 AppImage aktualisieren & ausführen", "🚀 Update AppImage & restart"),
|
||||||
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
("🚀 Updates & Versionen", "🚀 Updates & versions"),
|
||||||
|
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
|
||||||
|
(", {count} Elemente übersprungen", ", {count} elements skipped"),
|
||||||
|
("Nicht importiert: {err}", "Not imported: {err}"),
|
||||||
|
("Importieren", "Import"),
|
||||||
|
("📄 Importieren", "📄 Import"),
|
||||||
|
("Datei beschädigt: {err}", "File damaged: {err}"),
|
||||||
|
("Datei nicht lesbar: {err}", "File not readable: {err}"),
|
||||||
|
("Die Datei enthält keine Seiten", "The file contains no pages"),
|
||||||
|
("Keine Xournal-Datei", "Not a Xournal file"),
|
||||||
|
("Keine Rnote-Datei", "Not an Rnote file"),
|
||||||
|
("Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern", "Rnote file too old ({version}): please open it in a current Rnote and save it again"),
|
||||||
|
("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)", "No folders or importable files (PDF, Xournal++, Rnote, Saber)"),
|
||||||
|
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj), Rnote (.rnote) and Saber (.sbn2, .sba): strokes, shapes, texts and images are taken over, for Xournal++ and Saber also PDF backgrounds."),
|
||||||
|
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++, Rnote and Saber as a new notebook and write on them"),
|
||||||
|
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
|
||||||
|
("Keine Saber-Datei", "Not a Saber file"),
|
||||||
|
("Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern", "Saber file too old: please open it in a current Saber and save it again"),
|
||||||
|
("Die Notizbücher liegen bereits dort", "The notebooks are already there"),
|
||||||
|
("Konfliktkopien als eigene Seiten übernommen: {count}", "Conflict copies added as separate pages: {count}"),
|
||||||
|
("Speicherort geändert, {count} Notizbücher kopiert", "Storage location changed, {count} notebooks copied"),
|
||||||
|
("Speicherort geändert: {path}", "Storage location changed: {path}"),
|
||||||
|
("Speicherort nicht nutzbar: {err}", "Storage location not usable: {err}"),
|
||||||
|
("Änderungen von einem anderen Gerät übernommen", "Changes from another device taken over"),
|
||||||
|
("⚠ Auch auf '{device}' geöffnet – Änderungen dort können deine überschreiben", "⚠ Also open on '{device}' – changes there may overwrite yours"),
|
||||||
|
("⚠ Gerade auf '{device}' geöffnet – Änderungen dort können deine überschreiben", "⚠ Just opened on '{device}' – changes there may overwrite yours"),
|
||||||
|
("Konfliktkopie", "Conflict copy"),
|
||||||
|
("Konfliktkopie: {title}", "Conflict copy: {title}"),
|
||||||
|
("Die Notizbücher liegen dann im Unterordner „ink-notes“ des gewählten Ordners, z. B. im Nextcloud- oder Google-Drive-Ordner. Ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert; die alten bleiben erhalten.", "The notebooks are then kept in the subfolder “ink-notes” of the chosen folder, e.g. in your Nextcloud or Google Drive folder. If it is still empty, the existing notebooks are copied there; the old ones are kept."),
|
||||||
|
("Keine Unterordner", "No subfolders"),
|
||||||
|
("SPEICHERORT", "STORAGE LOCATION"),
|
||||||
|
("Speicherort wählen", "Choose storage location"),
|
||||||
|
("Standard", "Default"),
|
||||||
|
("Z. B. einen Nextcloud- oder Google-Drive-Ordner wählen, um die Notizbücher mit anderen Geräten abzugleichen", "E.g. choose a Nextcloud or Google Drive folder to sync the notebooks with other devices"),
|
||||||
|
("Zurück zum Standard-Speicherort", "Back to the default storage location"),
|
||||||
|
("✔ Diesen Ordner wählen", "✔ Choose this folder"),
|
||||||
|
("📁 Ändern …", "📁 Change …"),
|
||||||
|
("📁 Speicherort wählen", "📁 Choose storage location"),
|
||||||
|
("Update installiert – gilt ab dem nächsten Start", "Update installed – takes effect at the next start"),
|
||||||
|
("🔄 Update installieren", "🔄 Install update"),
|
||||||
|
("Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes Programm unverändert.", "Could not create backup ({path}): {e}. Update cancelled, old program unchanged."),
|
||||||
|
("Konnte Programm nicht ersetzen: {e}. Altes Programm bleibt unverändert.", "Could not replace the program: {e}. The old program stays unchanged."),
|
||||||
|
("Ungültiger Programmpfad.", "Invalid program path."),
|
||||||
];
|
];
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
|
||||||
|
//! (`xopp`), Rnote (`rnote`) and Saber (`saber`). This module holds what the importers
|
||||||
|
//! share: building strokes, texts and images in the stored format and creating the notebook.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::document::{Notebook, Page, PageBackground, StrokeTool};
|
||||||
|
use crate::storage::{self, NotebookLayout, NotebookManifest, SampleData, StrokeData};
|
||||||
|
use crate::t;
|
||||||
|
use crate::text::{TextBox, TextFont};
|
||||||
|
|
||||||
|
/// Largest accepted file after decompression (guards against gzip bombs).
|
||||||
|
const MAX_UNCOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Alpha of imported highlighter strokes that are stored opaque (as the app's highlighters).
|
||||||
|
pub const HIGHLIGHTER_ALPHA: f32 = 0.35;
|
||||||
|
|
||||||
|
/// What could not be taken over.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct ImportReport {
|
||||||
|
pub skipped: usize,
|
||||||
|
/// The PDF background was referenced but not found or unreadable
|
||||||
|
pub missing_pdf: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of an importer: pages, the next free stroke id, the layout (`None`: default).
|
||||||
|
pub struct Imported {
|
||||||
|
pub pages: Vec<Page>,
|
||||||
|
pub next_stroke_id: u64,
|
||||||
|
pub layout: Option<NotebookLayout>,
|
||||||
|
pub report: ImportReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can the file be imported (by its name)?
|
||||||
|
pub fn is_importable_path(path: &Path) -> bool {
|
||||||
|
crate::file_picker::is_pdf_path(path)
|
||||||
|
|| crate::xopp::is_xopp_path(path)
|
||||||
|
|| crate::rnote::is_rnote_path(path)
|
||||||
|
|| crate::saber::is_saber_path(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports a PDF, Xournal++, Rnote or Saber file as a new notebook titled `title`.
|
||||||
|
pub fn import_file(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||||
|
if crate::xopp::is_xopp_path(path) {
|
||||||
|
crate::xopp::create_notebook_from_xopp(base_dir, title, path)
|
||||||
|
} else if crate::rnote::is_rnote_path(path) {
|
||||||
|
crate::rnote::create_notebook_from_rnote(base_dir, title, path)
|
||||||
|
} else if crate::saber::is_saber_path(path) {
|
||||||
|
crate::saber::create_notebook_from_saber(base_dir, title, path)
|
||||||
|
} else {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
storage::create_notebook_from_pdf(base_dir, title, bytes)
|
||||||
|
.map(|(nb, manifest, dir)| (nb, manifest, dir, ImportReport::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decompresses gzip data; other data is returned unchanged.
|
||||||
|
pub fn gunzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
if !bytes.starts_with(&[0x1f, 0x8b]) {
|
||||||
|
return Ok(bytes.to_vec());
|
||||||
|
}
|
||||||
|
let mut data = Vec::new();
|
||||||
|
flate2::read::MultiGzDecoder::new(bytes)
|
||||||
|
.take(MAX_UNCOMPRESSED_BYTES)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.map_err(|e| t!("Datei nicht lesbar: {err}", err = e))?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new notebook and fills it with the pages `build` creates in its folder (images
|
||||||
|
/// and PDFs go to its asset folder). On failure nothing is left behind.
|
||||||
|
pub fn create_notebook(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
build: impl FnOnce(&Path) -> Result<Imported, String>,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||||
|
let (_, _, dir) = storage::create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
|
||||||
|
let result = (|| {
|
||||||
|
let imported = build(&dir)?;
|
||||||
|
let page_count = imported.pages.len() as u64;
|
||||||
|
let notebook =
|
||||||
|
Notebook::from_pages(imported.pages, 0, imported.next_stroke_id, page_count + 1);
|
||||||
|
let mut manifest = storage::load_manifest(&dir).map_err(|e| e.to_string())?;
|
||||||
|
if imported.layout.is_some() {
|
||||||
|
manifest.layout = imported.layout;
|
||||||
|
}
|
||||||
|
storage::save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||||
|
Ok((notebook, manifest, imported.report))
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok((notebook, manifest, report)) => Ok((notebook, manifest, dir, report)),
|
||||||
|
Err(err) => {
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a page from stored-format strokes.
|
||||||
|
pub fn page(
|
||||||
|
id: u64,
|
||||||
|
size: glam::Vec2,
|
||||||
|
background: PageBackground,
|
||||||
|
pdf_background: Option<crate::document::PdfBackground>,
|
||||||
|
strokes: Vec<StrokeData>,
|
||||||
|
) -> Page {
|
||||||
|
storage::PageData {
|
||||||
|
format_version: storage::CURRENT_FORMAT_VERSION,
|
||||||
|
id,
|
||||||
|
background,
|
||||||
|
pdf_background,
|
||||||
|
title: None,
|
||||||
|
size: (size != crate::document::A4_SIZE).then(|| size.to_array()),
|
||||||
|
strokes,
|
||||||
|
}
|
||||||
|
.to_page()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our stroke width and the sample pressures for absolute widths per point (page units):
|
||||||
|
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
|
||||||
|
/// ones less (down to a third of the widest).
|
||||||
|
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
|
||||||
|
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
|
||||||
|
let width = widest / 1.5;
|
||||||
|
let pressures = widths
|
||||||
|
.iter()
|
||||||
|
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
|
||||||
|
.collect();
|
||||||
|
(width, pressures)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pen or highlighter stroke through `points` (page units) with the given widths per point.
|
||||||
|
pub fn ink_stroke(
|
||||||
|
id: u64,
|
||||||
|
mut color: [f32; 4],
|
||||||
|
highlighter: bool,
|
||||||
|
points: &[glam::Vec2],
|
||||||
|
width: f32,
|
||||||
|
pressures: &[f32],
|
||||||
|
) -> StrokeData {
|
||||||
|
let tool = if highlighter {
|
||||||
|
if color[3] >= 0.99 {
|
||||||
|
color[3] = HIGHLIGHTER_ALPHA;
|
||||||
|
}
|
||||||
|
StrokeTool::Highlighter
|
||||||
|
} else {
|
||||||
|
// Pens are always opaque (translucent pens are read as highlighters)
|
||||||
|
color[3] = 1.0;
|
||||||
|
StrokeTool::Pen
|
||||||
|
};
|
||||||
|
let samples = points
|
||||||
|
.iter()
|
||||||
|
.zip(pressures)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (p, &pressure))| SampleData {
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
p: pressure,
|
||||||
|
t: i as f64 * 0.005,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
StrokeData {
|
||||||
|
id,
|
||||||
|
color,
|
||||||
|
samples,
|
||||||
|
shape: None,
|
||||||
|
tool,
|
||||||
|
width,
|
||||||
|
finalized: true,
|
||||||
|
image: None,
|
||||||
|
text: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text field with its upper left corner at `origin` and the axes (page vector per unit to
|
||||||
|
/// the right and down). Without `wrap_width` it is as wide as its longest line.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn text_stroke(
|
||||||
|
id: u64,
|
||||||
|
content: &str,
|
||||||
|
size: f32,
|
||||||
|
font: TextFont,
|
||||||
|
origin: glam::Vec2,
|
||||||
|
axes: [glam::Vec2; 2],
|
||||||
|
wrap_width: Option<f32>,
|
||||||
|
mut color: [f32; 4],
|
||||||
|
) -> StrokeData {
|
||||||
|
let wrap_width = wrap_width.unwrap_or_else(|| {
|
||||||
|
let unwrapped = TextBox::new(content.to_string(), size, font, f32::MAX / 4.0);
|
||||||
|
let widest = unwrapped
|
||||||
|
.layout()
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| line.width)
|
||||||
|
.fold(0.0, f32::max);
|
||||||
|
widest + size
|
||||||
|
});
|
||||||
|
let text = TextBox::new(content.to_string(), size, font, wrap_width);
|
||||||
|
let corners = crate::text::frame_corners(origin, axes, text.box_size());
|
||||||
|
color[3] = 1.0;
|
||||||
|
StrokeData {
|
||||||
|
id,
|
||||||
|
color,
|
||||||
|
samples: corner_samples(corners),
|
||||||
|
shape: None,
|
||||||
|
tool: StrokeTool::Pen,
|
||||||
|
width: 0.0,
|
||||||
|
finalized: true,
|
||||||
|
image: None,
|
||||||
|
text: Some(text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Image with the corners upper left, upper right, lower right, lower left.
|
||||||
|
pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeData {
|
||||||
|
StrokeData {
|
||||||
|
id,
|
||||||
|
color: [1.0, 1.0, 1.0, 1.0],
|
||||||
|
samples: corner_samples(corners),
|
||||||
|
shape: None,
|
||||||
|
tool: StrokeTool::Pen,
|
||||||
|
width: 0.0,
|
||||||
|
finalized: true,
|
||||||
|
image: Some(asset),
|
||||||
|
text: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
|
||||||
|
pub fn unpremultiply(pixels: &mut [u8]) {
|
||||||
|
for px in pixels.as_chunks_mut::<4>().0 {
|
||||||
|
let a = px[3] as u32;
|
||||||
|
if a > 0 && a < 255 {
|
||||||
|
for c in &mut px[..3] {
|
||||||
|
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
|
||||||
|
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
|
||||||
|
use resvg::{tiny_skia, usvg};
|
||||||
|
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
|
||||||
|
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
|
||||||
|
let px_per_unit = 250.0 / 254.0;
|
||||||
|
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
|
||||||
|
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
|
||||||
|
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
|
||||||
|
let transform = tiny_skia::Transform::from_scale(
|
||||||
|
width as f32 / tree.size().width(),
|
||||||
|
height as f32 / tree.size().height(),
|
||||||
|
);
|
||||||
|
resvg::render(&tree, transform, &mut pixmap.as_mut());
|
||||||
|
let mut pixels = pixmap.take();
|
||||||
|
unpremultiply(&mut pixels);
|
||||||
|
Some((width, height, pixels))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
|
||||||
|
corners
|
||||||
|
.iter()
|
||||||
|
.map(|c| SampleData {
|
||||||
|
x: c.x,
|
||||||
|
y: c.y,
|
||||||
|
p: 1.0,
|
||||||
|
t: 0.0,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fit_widths_keeps_the_drawn_widths() {
|
||||||
|
let (width, pressures) = fit_widths(&[30.0, 15.0, 10.0, 2.0]);
|
||||||
|
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
|
||||||
|
assert!((drawn[0] - 30.0).abs() < 1e-3);
|
||||||
|
assert!((drawn[1] - 15.0).abs() < 1e-3);
|
||||||
|
assert!((drawn[2] - 10.0).abs() < 1e-3);
|
||||||
|
// Thinner than a third of the widest point is not possible
|
||||||
|
assert!((drawn[3] - 10.0).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -1,9 +1,13 @@
|
|||||||
//! 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 assets;
|
pub mod assets;
|
||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
|
pub mod disk_watch;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod file_picker;
|
pub mod file_picker;
|
||||||
@@ -12,13 +16,19 @@ pub mod geometry;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod import;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod osk;
|
pub mod osk;
|
||||||
pub mod page_export;
|
pub mod page_export;
|
||||||
|
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 ruler;
|
pub mod ruler;
|
||||||
|
pub mod saber;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
@@ -27,3 +37,38 @@ pub mod text;
|
|||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
|
pub mod xopp;
|
||||||
|
|
||||||
|
/// Entry point on Android (Issue #42), called by the native activity.
|
||||||
|
///
|
||||||
|
/// `scripts/package-android.sh` builds the library and packs it into an APK.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[no_mangle]
|
||||||
|
fn android_main(app: winit::platform::android::activity::AndroidApp) {
|
||||||
|
use winit::platform::android::EventLoopBuilderExtAndroid;
|
||||||
|
|
||||||
|
android_logger::init_once(
|
||||||
|
android_logger::Config::default()
|
||||||
|
.with_max_level(log::LevelFilter::Info)
|
||||||
|
.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() {
|
||||||
|
paths::set_app_dir(dir);
|
||||||
|
}
|
||||||
|
let event_loop = match winit::event_loop::EventLoopBuilder::new()
|
||||||
|
.with_android_app(app)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(event_loop) => event_loop,
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("event loop: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(err) = app::run_with(event_loop) {
|
||||||
|
log::error!("{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-4250
File diff suppressed because it is too large.
Load diff
+89
-142
@@ -12,21 +12,59 @@
|
|||||||
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
|
//! ohnehin nicht aus. Daher meldet das eigene Objekt einen Platzhalter als Umgebungstext und
|
||||||
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
|
//! übersetzt die Löschwünsche in Tastendrücke (Rücktaste/Entf) für egui.
|
||||||
|
|
||||||
use std::ffi::c_void;
|
#[cfg(target_os = "linux")]
|
||||||
|
mod wayland;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use wayland::KeyboardRequester;
|
||||||
|
|
||||||
use wayland_client::{
|
/// Android: the soft keyboard is shown only when asked to (Issue #42). Its text comes through
|
||||||
delegate_noop,
|
/// the activity's input connection (`android_bridge`); keys like Enter or Backspace may also
|
||||||
protocol::{wl_registry, wl_seat},
|
/// arrive as key events through winit.
|
||||||
Connection, Dispatch, EventQueue, QueueHandle,
|
#[cfg(target_os = "android")]
|
||||||
};
|
pub struct KeyboardRequester;
|
||||||
use wayland_protocols::wp::text_input::zv3::client::{
|
|
||||||
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
|
||||||
zwp_text_input_v3::{self, ZwpTextInputV3},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
|
#[cfg(target_os = "android")]
|
||||||
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
|
impl KeyboardRequester {
|
||||||
const SURROUNDING_PLACEHOLDER: &str = " ";
|
/// # 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 {
|
||||||
|
/// # Safety
|
||||||
|
/// Nothing to uphold; `unsafe` like the Wayland version.
|
||||||
|
pub unsafe fn for_window(_window: &winit::window::Window) -> Option<Self> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(&mut self) {}
|
||||||
|
|
||||||
|
pub fn release(&mut self) {}
|
||||||
|
|
||||||
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
|
/// Wandelt Texteingaben ohne Vorschau (Preedit) in normale Texteingaben um.
|
||||||
///
|
///
|
||||||
@@ -67,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).
|
||||||
@@ -74,135 +136,6 @@ pub fn should_request(was_active: bool, active: bool, pressed: bool) -> bool {
|
|||||||
active && (!was_active || pressed)
|
active && (!was_active || pressed)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct State {
|
|
||||||
seat: Option<wl_seat::WlSeat>,
|
|
||||||
manager: Option<ZwpTextInputManagerV3>,
|
|
||||||
text_input: Option<ZwpTextInputV3>,
|
|
||||||
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
|
|
||||||
delete_back: u32,
|
|
||||||
delete_forward: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
|
|
||||||
pub struct KeyboardRequester {
|
|
||||||
conn: Connection,
|
|
||||||
queue: EventQueue<State>,
|
|
||||||
state: State,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyboardRequester {
|
|
||||||
/// Hängt sich an die Wayland-Verbindung des Fensters.
|
|
||||||
///
|
|
||||||
/// # Safety
|
|
||||||
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
|
|
||||||
/// der `KeyboardRequester` lebt.
|
|
||||||
pub unsafe fn new(wl_display: *mut c_void) -> Self {
|
|
||||||
// Safety: an den Aufrufer weitergereicht
|
|
||||||
let backend =
|
|
||||||
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
|
|
||||||
let conn = Connection::from_backend(backend);
|
|
||||||
let queue = conn.new_event_queue();
|
|
||||||
conn.display().get_registry(&queue.handle(), ());
|
|
||||||
let _ = conn.flush();
|
|
||||||
Self {
|
|
||||||
conn,
|
|
||||||
queue,
|
|
||||||
state: State::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
|
|
||||||
pub fn pump(&mut self) {
|
|
||||||
let _ = self.queue.dispatch_pending(&mut self.state);
|
|
||||||
if self.state.text_input.is_none() {
|
|
||||||
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
|
|
||||||
self.state.text_input =
|
|
||||||
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
|
|
||||||
/// winit kommen (also nach `handle_platform_output`).
|
|
||||||
pub fn request(&mut self) {
|
|
||||||
self.pump();
|
|
||||||
if let Some(text_input) = &self.state.text_input {
|
|
||||||
text_input.enable();
|
|
||||||
self.send_surrounding_placeholder();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tastendrücke für das seit dem letzten Aufruf gewünschte Löschen (vor `begin_frame`
|
|
||||||
/// aufrufen und an die egui-Eingabe anhängen)
|
|
||||||
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
|
||||||
self.pump();
|
|
||||||
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
|
|
||||||
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
|
|
||||||
let back = std::mem::take(&mut self.state.delete_back);
|
|
||||||
let forward = std::mem::take(&mut self.state.delete_forward);
|
|
||||||
deletion_key_events(back, forward)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_surrounding_placeholder(&self) {
|
|
||||||
if let Some(text_input) = &self.state.text_input {
|
|
||||||
let len = SURROUNDING_PLACEHOLDER.len() as i32;
|
|
||||||
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
|
|
||||||
text_input.commit();
|
|
||||||
let _ = self.conn.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
registry: &wl_registry::WlRegistry,
|
|
||||||
event: wl_registry::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
qh: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
if let wl_registry::Event::Global {
|
|
||||||
name, interface, ..
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
match interface.as_str() {
|
|
||||||
"wl_seat" if state.seat.is_none() => {
|
|
||||||
state.seat = Some(registry.bind(name, 1, qh, ()));
|
|
||||||
}
|
|
||||||
"zwp_text_input_manager_v3" if state.manager.is_none() => {
|
|
||||||
state.manager = Some(registry.bind(name, 1, qh, ()));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate_noop!(State: ignore wl_seat::WlSeat);
|
|
||||||
delegate_noop!(State: ZwpTextInputManagerV3);
|
|
||||||
impl Dispatch<ZwpTextInputV3, ()> for State {
|
|
||||||
fn event(
|
|
||||||
state: &mut Self,
|
|
||||||
_: &ZwpTextInputV3,
|
|
||||||
event: zwp_text_input_v3::Event,
|
|
||||||
_: &(),
|
|
||||||
_: &Connection,
|
|
||||||
_: &QueueHandle<Self>,
|
|
||||||
) {
|
|
||||||
// Getippter Text kommt über winit; hier nur das Löschen
|
|
||||||
if let zwp_text_input_v3::Event::DeleteSurroundingText {
|
|
||||||
before_length,
|
|
||||||
after_length,
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
state.delete_back += before_length;
|
|
||||||
state.delete_forward += after_length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -286,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));
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//! Wayland part of [`super`]: an own text-input object on the window's connection.
|
||||||
|
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
use wayland_client::{
|
||||||
|
delegate_noop,
|
||||||
|
protocol::{wl_registry, wl_seat},
|
||||||
|
Connection, Dispatch, EventQueue, QueueHandle,
|
||||||
|
};
|
||||||
|
use wayland_protocols::wp::text_input::zv3::client::{
|
||||||
|
zwp_text_input_manager_v3::ZwpTextInputManagerV3,
|
||||||
|
zwp_text_input_v3::{self, ZwpTextInputV3},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::deletion_key_events;
|
||||||
|
|
||||||
|
/// Platzhalter-Umgebungstext: ein Zeichen vor der Schreibmarke, damit die Rücktaste der
|
||||||
|
/// Bildschirmtastatur immer etwas zu löschen hat (ein Byte = ein Zeichen)
|
||||||
|
const SURROUNDING_PLACEHOLDER: &str = " ";
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct State {
|
||||||
|
seat: Option<wl_seat::WlSeat>,
|
||||||
|
manager: Option<ZwpTextInputManagerV3>,
|
||||||
|
text_input: Option<ZwpTextInputV3>,
|
||||||
|
/// Von der Bildschirmtastatur gewünschtes Löschen (Zeichen vor/nach der Schreibmarke)
|
||||||
|
delete_back: u32,
|
||||||
|
delete_forward: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigenes text-input-Objekt, mit dem ink-notes die Bildschirmtastatur anfordert
|
||||||
|
pub struct KeyboardRequester {
|
||||||
|
conn: Connection,
|
||||||
|
queue: EventQueue<State>,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyboardRequester {
|
||||||
|
/// Requester for the window if it runs on Wayland.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// The window must outlive the requester.
|
||||||
|
pub unsafe fn for_window(window: &winit::window::Window) -> Option<Self> {
|
||||||
|
use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||||
|
match window.display_handle().map(|handle| handle.as_raw()) {
|
||||||
|
// Safety: passed on to the caller
|
||||||
|
Ok(RawDisplayHandle::Wayland(handle)) => {
|
||||||
|
Some(unsafe { Self::new(handle.display.as_ptr()) })
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt sich an die Wayland-Verbindung des Fensters.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `wl_display` muss auf die `wl_display` des Fensters zeigen und gültig bleiben, solange
|
||||||
|
/// der `KeyboardRequester` lebt.
|
||||||
|
pub unsafe fn new(wl_display: *mut c_void) -> Self {
|
||||||
|
// Safety: an den Aufrufer weitergereicht
|
||||||
|
let backend =
|
||||||
|
unsafe { wayland_backend::client::Backend::from_foreign_display(wl_display.cast()) };
|
||||||
|
let conn = Connection::from_backend(backend);
|
||||||
|
let queue = conn.new_event_queue();
|
||||||
|
conn.display().get_registry(&queue.handle(), ());
|
||||||
|
let _ = conn.flush();
|
||||||
|
Self {
|
||||||
|
conn,
|
||||||
|
queue,
|
||||||
|
state: State::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verarbeitet eingetroffene Ereignisse; einmal pro Frame aufrufen.
|
||||||
|
pub fn pump(&mut self) {
|
||||||
|
let _ = self.queue.dispatch_pending(&mut self.state);
|
||||||
|
if self.state.text_input.is_none() {
|
||||||
|
if let (Some(manager), Some(seat)) = (&self.state.manager, &self.state.seat) {
|
||||||
|
self.state.text_input =
|
||||||
|
Some(manager.get_text_input(seat, &self.queue.handle(), ()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bittet den Compositor, die Bildschirmtastatur einzublenden. Muss nach dem `enable` von
|
||||||
|
/// winit kommen (also nach `handle_platform_output`).
|
||||||
|
pub fn request(&mut self) {
|
||||||
|
self.pump();
|
||||||
|
if let Some(text_input) = &self.state.text_input {
|
||||||
|
text_input.enable();
|
||||||
|
self.send_surrounding_placeholder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`
|
||||||
|
/// aufrufen und an die egui-Eingabe anhängen)
|
||||||
|
pub fn take_deletions(&mut self) -> Vec<egui::Event> {
|
||||||
|
self.pump();
|
||||||
|
// Den Platzhalter hier nicht neu melden: Die Tastatur löscht bei jeder neuen Meldung
|
||||||
|
// erneut, solange die Rücktaste gedrückt ist – ein kurzes Antippen löschte sonst doppelt.
|
||||||
|
let back = std::mem::take(&mut self.state.delete_back);
|
||||||
|
let forward = std::mem::take(&mut self.state.delete_forward);
|
||||||
|
deletion_key_events(back, forward)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_surrounding_placeholder(&self) {
|
||||||
|
if let Some(text_input) = &self.state.text_input {
|
||||||
|
let len = SURROUNDING_PLACEHOLDER.len() as i32;
|
||||||
|
text_input.set_surrounding_text(SURROUNDING_PLACEHOLDER.to_owned(), len, len);
|
||||||
|
text_input.commit();
|
||||||
|
let _ = self.conn.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let wl_registry::Event::Global {
|
||||||
|
name, interface, ..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
match interface.as_str() {
|
||||||
|
"wl_seat" if state.seat.is_none() => {
|
||||||
|
state.seat = Some(registry.bind(name, 1, qh, ()));
|
||||||
|
}
|
||||||
|
"zwp_text_input_manager_v3" if state.manager.is_none() => {
|
||||||
|
state.manager = Some(registry.bind(name, 1, qh, ()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate_noop!(State: ignore wl_seat::WlSeat);
|
||||||
|
delegate_noop!(State: ZwpTextInputManagerV3);
|
||||||
|
impl Dispatch<ZwpTextInputV3, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &ZwpTextInputV3,
|
||||||
|
event: zwp_text_input_v3::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
// Getippter Text kommt über winit; hier nur das Löschen
|
||||||
|
if let zwp_text_input_v3::Event::DeleteSurroundingText {
|
||||||
|
before_length,
|
||||||
|
after_length,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
state.delete_back += before_length;
|
||||||
|
state.delete_forward += after_length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//! Folders for notebooks, settings and downloads on each platform (Issues #42, #48).
|
||||||
|
//!
|
||||||
|
//! - Linux: XDG (`~/.local/share/ink-notes`, `~/.config/ink-notes`, `~/.cache/ink-notes`)
|
||||||
|
//! - Windows: `%APPDATA%\ink-notes` for data and settings, `%LOCALAPPDATA%\ink-notes` for
|
||||||
|
//! downloads
|
||||||
|
//! - Android: the app's internal folder, set once at startup with [`set_app_dir`]
|
||||||
|
//!
|
||||||
|
//! The environment is read on every call, so tests can point the folders elsewhere.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Fixed app folder (Android: internal storage of the app)
|
||||||
|
static APP_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Puts data, settings and downloads below `dir` (Android, where there is no XDG or home).
|
||||||
|
pub fn set_app_dir(dir: PathBuf) {
|
||||||
|
let _ = APP_DIR.set(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Value of an environment variable, unless empty.
|
||||||
|
fn env_dir(key: &str) -> Option<PathBuf> {
|
||||||
|
std::env::var_os(key)
|
||||||
|
.filter(|v| !v.to_string_lossy().trim().is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home folder of the user.
|
||||||
|
pub fn home_dir() -> Option<PathBuf> {
|
||||||
|
if cfg!(windows) {
|
||||||
|
env_dir("USERPROFILE").or_else(|| env_dir("HOME"))
|
||||||
|
} else {
|
||||||
|
env_dir("HOME")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder for notebooks and emergency backups.
|
||||||
|
pub fn data_dir() -> Option<PathBuf> {
|
||||||
|
if let Some(dir) = APP_DIR.get() {
|
||||||
|
return Some(dir.clone());
|
||||||
|
}
|
||||||
|
if cfg!(windows) {
|
||||||
|
return env_dir("APPDATA").map(|d| d.join("ink-notes"));
|
||||||
|
}
|
||||||
|
env_dir("XDG_DATA_HOME")
|
||||||
|
.or_else(|| home_dir().map(|h| h.join(".local").join("share")))
|
||||||
|
.map(|d| d.join("ink-notes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder for the settings (`ui.json`).
|
||||||
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
|
if let Some(dir) = APP_DIR.get() {
|
||||||
|
return Some(dir.join("config"));
|
||||||
|
}
|
||||||
|
if cfg!(windows) {
|
||||||
|
return env_dir("APPDATA").map(|d| d.join("ink-notes"));
|
||||||
|
}
|
||||||
|
env_dir("XDG_CONFIG_HOME")
|
||||||
|
.or_else(|| home_dir().map(|h| h.join(".config")))
|
||||||
|
.map(|d| d.join("ink-notes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder for downloaded updates.
|
||||||
|
pub fn cache_dir() -> Option<PathBuf> {
|
||||||
|
if let Some(dir) = APP_DIR.get() {
|
||||||
|
return Some(dir.join("cache"));
|
||||||
|
}
|
||||||
|
if cfg!(windows) {
|
||||||
|
return env_dir("LOCALAPPDATA").map(|d| d.join("ink-notes"));
|
||||||
|
}
|
||||||
|
env_dir("XDG_CACHE_HOME")
|
||||||
|
.or_else(|| home_dir().map(|h| h.join(".cache")))
|
||||||
|
.map(|d| d.join("ink-notes"))
|
||||||
|
}
|
||||||
+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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+299
@@ -58,6 +58,255 @@ pub fn display_backend_from_env() -> DisplayBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event loop for the desktop; on Linux with the display backend chosen above.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub fn desktop_event_loop() -> Result<winit::event_loop::EventLoop<()>, winit::error::EventLoopError>
|
||||||
|
{
|
||||||
|
#[allow(unused_mut)]
|
||||||
|
let mut builder = winit::event_loop::EventLoopBuilder::new();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
use winit::platform::{wayland::EventLoopBuilderExtWayland, x11::EventLoopBuilderExtX11};
|
||||||
|
match display_backend_from_env() {
|
||||||
|
DisplayBackend::X11 => {
|
||||||
|
eprintln!("[Anzeige] Verwende X11 (unter WSL Standard; übersteuerbar mit INK_NOTES_DISPLAY=wayland)");
|
||||||
|
builder.with_x11();
|
||||||
|
}
|
||||||
|
DisplayBackend::Wayland => {
|
||||||
|
eprintln!("[Anzeige] Verwende Wayland (INK_NOTES_DISPLAY=wayland)");
|
||||||
|
builder.with_wayland();
|
||||||
|
}
|
||||||
|
DisplayBackend::Automatic => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
/// show the app icon for it.
|
||||||
|
pub fn with_app_id(
|
||||||
|
builder: winit::window::WindowBuilder,
|
||||||
|
#[allow(unused_variables)] app_id: &str,
|
||||||
|
) -> winit::window::WindowBuilder {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
let builder = winit::platform::wayland::WindowBuilderExtWayland::with_name(
|
||||||
|
builder,
|
||||||
|
app_id,
|
||||||
|
"ink-notes",
|
||||||
|
);
|
||||||
|
winit::platform::x11::WindowBuilderExtX11::with_name(builder, app_id, "ink-notes")
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ignores touches on the client-side title bar until `until` (palm rejection while the pen
|
||||||
|
/// is near; only Wayland draws its own title bar).
|
||||||
|
pub fn ignore_decoration_touches_until(
|
||||||
|
#[allow(unused_variables)] until: Option<std::time::Instant>,
|
||||||
|
) {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
winit::platform::wayland::ignore_decoration_touches_until(until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time of a touch event as reported by the compositor (milliseconds, Wayland only).
|
||||||
|
pub fn take_touch_time(
|
||||||
|
#[allow(unused_variables)] id: u64,
|
||||||
|
#[allow(unused_variables)] phase: winit::event::TouchPhase,
|
||||||
|
) -> Option<u32> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
return winit::platform::wayland::take_touch_time(id, phase);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
/// Bringt die Ereignisse eines Tablet-Frames in eine brauchbare Reihenfolge: octotablet meldet
|
||||||
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
/// das Aufsetzen des Stifts (`Down`) vor der Position desselben Frames (`Pose`). Bis dahin
|
||||||
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
/// gälte noch die alte Position – kam der Stift schnell heran (Annähern und Aufsetzen im selben
|
||||||
@@ -91,6 +340,56 @@ pub fn down_after_pose<T>(
|
|||||||
ordered
|
ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// System clipboard: arboard on the desktop; on Android not available yet (Issue #42).
|
||||||
|
pub mod clipboard {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub use arboard::{Clipboard, ImageData};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub use android::{Clipboard, ImageData};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android {
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
pub struct ImageData<'a> {
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
pub bytes: Cow<'a, [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageData<'_> {
|
||||||
|
pub fn to_owned_img(&self) -> ImageData<'static> {
|
||||||
|
ImageData {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
bytes: Cow::Owned(self.bytes.to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Clipboard;
|
||||||
|
|
||||||
|
impl Clipboard {
|
||||||
|
pub fn new() -> Result<Self, &'static str> {
|
||||||
|
Err("not available on Android")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_image(&mut self) -> Result<ImageData<'static>, &'static str> {
|
||||||
|
Err("not available on Android")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_text(&mut self) -> Result<String, &'static str> {
|
||||||
|
Err("not available on Android")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_image(&mut self, _image: ImageData) -> Result<(), &'static str> {
|
||||||
|
Err("not available on Android")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+907
@@ -0,0 +1,907 @@
|
|||||||
|
//! Import of Rnote notebooks (.rnote) (Issue #47).
|
||||||
|
//!
|
||||||
|
//! An .rnote file is gzip-compressed JSON: `{"version": "0.x.y", "data": {"engine_snapshot":
|
||||||
|
//! {...}}}`. The snapshot is Rnote's internal state without a specification, so it is read
|
||||||
|
//! leniently as JSON and only what we need is picked out; files from Rnote 0.5.10 on are
|
||||||
|
//! supported (the layout of older ones differs too much). Coordinates are pixels at the
|
||||||
|
//! document's dpi.
|
||||||
|
//!
|
||||||
|
//! Pages: fixed-size and continuous documents become one page per Rnote page (a stroke goes
|
||||||
|
//! to the page its middle lies on), infinite documents an endless notebook.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use glam::DVec2;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::document::{Notebook, PageBackground};
|
||||||
|
use crate::import::{self, ImportReport, Imported};
|
||||||
|
use crate::storage::{NotebookLayout, NotebookManifest, StrokeData};
|
||||||
|
use crate::t;
|
||||||
|
use crate::text::TextFont;
|
||||||
|
|
||||||
|
/// At most this many pages are created (protects against nonsensical document sizes).
|
||||||
|
const MAX_PAGES: usize = 2000;
|
||||||
|
|
||||||
|
/// Is the file by its name an Rnote notebook?
|
||||||
|
pub fn is_rnote_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(|e| e.eq_ignore_ascii_case("rnote"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed Rnote document, still in Rnote pixels.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RnoteDocument {
|
||||||
|
/// Page size in pixels and dpi
|
||||||
|
pub format: DVec2,
|
||||||
|
pub dpi: f64,
|
||||||
|
/// Document bounds (upper left corner and size)
|
||||||
|
pub origin: DVec2,
|
||||||
|
pub size: DVec2,
|
||||||
|
pub endless: bool,
|
||||||
|
pub background: PageBackground,
|
||||||
|
/// In drawing order
|
||||||
|
pub items: Vec<RnoteItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum RnoteItem {
|
||||||
|
Ink {
|
||||||
|
color: [f32; 4],
|
||||||
|
highlighter: bool,
|
||||||
|
points: Vec<DVec2>,
|
||||||
|
/// Drawn width per point
|
||||||
|
widths: Vec<f64>,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
content: String,
|
||||||
|
size: f64,
|
||||||
|
mono: bool,
|
||||||
|
/// Upper left corner and axes
|
||||||
|
affine: [DVec2; 3],
|
||||||
|
max_width: Option<f64>,
|
||||||
|
color: [f32; 4],
|
||||||
|
},
|
||||||
|
Bitmap {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
/// Straight (not premultiplied) RGBA
|
||||||
|
pixels: Vec<u8>,
|
||||||
|
corners: [DVec2; 4],
|
||||||
|
},
|
||||||
|
/// SVG image (Rnote stores imported PDF pages like this), rasterized on import
|
||||||
|
Svg { svg: String, corners: [DVec2; 4] },
|
||||||
|
/// Element without a counterpart or unreadable
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RnoteItem {
|
||||||
|
/// Point that decides which page the element belongs to.
|
||||||
|
fn anchor(&self) -> Option<DVec2> {
|
||||||
|
match self {
|
||||||
|
RnoteItem::Ink { points, .. } => {
|
||||||
|
let (min, max) = points.iter().fold(
|
||||||
|
(DVec2::splat(f64::MAX), DVec2::splat(f64::MIN)),
|
||||||
|
|(a, b), &p| (a.min(p), b.max(p)),
|
||||||
|
);
|
||||||
|
(!points.is_empty()).then(|| (min + max) / 2.0)
|
||||||
|
}
|
||||||
|
RnoteItem::Text { affine, .. } => Some(affine[2]),
|
||||||
|
RnoteItem::Bitmap { corners, .. } | RnoteItem::Svg { corners, .. } => {
|
||||||
|
Some((corners[0] + corners[2]) / 2.0)
|
||||||
|
}
|
||||||
|
RnoteItem::Unsupported => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(value: &Value, key: &str) -> Option<f64> {
|
||||||
|
value.get(key)?.as_f64().filter(|v| v.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vectors are stored as `[x, y]` (older versions possibly as `{"x": .., "y": ..}`).
|
||||||
|
fn vec2(value: &Value) -> Option<DVec2> {
|
||||||
|
match value {
|
||||||
|
Value::Array(a) if a.len() == 2 => Some(DVec2::new(a[0].as_f64()?, a[1].as_f64()?)),
|
||||||
|
Value::Object(_) => Some(DVec2::new(num(value, "x")?, num(value, "y")?)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affine transformation as x axis, y axis and translation. Since Rnote 0.15 `affine` holds
|
||||||
|
/// the six values; before, `transform.affine` held a column-major 3×3 matrix.
|
||||||
|
fn affine(value: &Value) -> Option<[DVec2; 3]> {
|
||||||
|
let values = value
|
||||||
|
.get("affine")
|
||||||
|
.or_else(|| value.get("transform")?.get("affine"))?
|
||||||
|
.as_array()?;
|
||||||
|
let v: Vec<f64> = values.iter().filter_map(Value::as_f64).collect();
|
||||||
|
let [a, b, c, d, e, f] = match v.len() {
|
||||||
|
6 => [v[0], v[1], v[2], v[3], v[4], v[5]],
|
||||||
|
9 => [v[0], v[1], v[3], v[4], v[6], v[7]],
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some([DVec2::new(a, b), DVec2::new(c, d), DVec2::new(e, f)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(affine: &[DVec2; 3], p: DVec2) -> DVec2 {
|
||||||
|
affine[0] * p.x + affine[1] * p.y + affine[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color(value: Option<&Value>) -> Option<[f32; 4]> {
|
||||||
|
let value = value.filter(|v| v.is_object())?;
|
||||||
|
let c = |k| num(value, k).unwrap_or(0.0).clamp(0.0, 1.0) as f32;
|
||||||
|
Some([
|
||||||
|
c("r"),
|
||||||
|
c("g"),
|
||||||
|
c("b"),
|
||||||
|
num(value, "a").map_or(1.0, |a| a.clamp(0.0, 1.0) as f32),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one variant of an externally tagged enum: `{"name": {...}}`.
|
||||||
|
fn variant(value: &Value) -> Option<(&str, &Value)> {
|
||||||
|
let object = value.as_object()?;
|
||||||
|
let (name, inner) = object.iter().next()?;
|
||||||
|
Some((name.as_str(), inner))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width for the pressure according to Rnote's pressure curve.
|
||||||
|
fn curve_width(curve: &str, width: f64, pressure: f64) -> f64 {
|
||||||
|
let p = pressure.clamp(0.0, 1.0);
|
||||||
|
match curve {
|
||||||
|
"const" => width,
|
||||||
|
"sqrt" => width * p.sqrt(),
|
||||||
|
"cbrt" => width * p.cbrt(),
|
||||||
|
"pow2" => width * p.powi(2),
|
||||||
|
"pow3" => width * p.powi(3),
|
||||||
|
_ => width * p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style options (smooth, rough or textured).
|
||||||
|
struct Style {
|
||||||
|
width: f64,
|
||||||
|
stroke_color: Option<[f32; 4]>,
|
||||||
|
fill_color: Option<[f32; 4]>,
|
||||||
|
pressure_curve: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style(value: Option<&Value>) -> Option<Style> {
|
||||||
|
let (kind, options) = variant(value?)?;
|
||||||
|
let curve = if kind == "rough" {
|
||||||
|
"const".to_string()
|
||||||
|
} else {
|
||||||
|
options
|
||||||
|
.get("pressure_curve")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("linear")
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
Some(Style {
|
||||||
|
width: num(options, "stroke_width").unwrap_or(1.0).max(0.1),
|
||||||
|
stroke_color: color(options.get("stroke_color")),
|
||||||
|
fill_color: color(options.get("fill_color")),
|
||||||
|
pressure_curve: curve,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points along a Bézier curve (without its start), at most every 2 pixels; for a line
|
||||||
|
/// just its end.
|
||||||
|
fn bezier(start: DVec2, controls: &[DVec2], end: DVec2) -> Vec<DVec2> {
|
||||||
|
if controls.is_empty() {
|
||||||
|
return vec![end];
|
||||||
|
}
|
||||||
|
let length = controls
|
||||||
|
.iter()
|
||||||
|
.chain([&end])
|
||||||
|
.fold((0.0, start), |(sum, prev), &p| (sum + prev.distance(p), p))
|
||||||
|
.0;
|
||||||
|
let steps = ((length / 2.0).ceil() as usize).clamp(1, 32);
|
||||||
|
(1..=steps)
|
||||||
|
.map(|i| {
|
||||||
|
let t = i as f64 / steps as f64;
|
||||||
|
let u = 1.0 - t;
|
||||||
|
match controls {
|
||||||
|
[cp] => start * u * u + *cp * 2.0 * u * t + end * t * t,
|
||||||
|
[cp1, cp2] => {
|
||||||
|
start * u * u * u
|
||||||
|
+ *cp1 * 3.0 * u * u * t
|
||||||
|
+ *cp2 * 3.0 * u * t * t
|
||||||
|
+ end * t * t * t
|
||||||
|
}
|
||||||
|
_ => start + (end - start) * t,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn element(value: &Value) -> Option<(DVec2, f64)> {
|
||||||
|
Some((
|
||||||
|
vec2(value.get("pos")?)?,
|
||||||
|
num(value, "pressure").unwrap_or(0.5),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brushstroke(value: &Value, highlighter: bool) -> Option<RnoteItem> {
|
||||||
|
let style = style(value.get("style"))?;
|
||||||
|
let color = style.stroke_color?;
|
||||||
|
let path = value.get("path")?;
|
||||||
|
let (mut prev, mut prev_pressure) = element(path.get("start")?)?;
|
||||||
|
let mut points = vec![prev];
|
||||||
|
let mut pressures = vec![prev_pressure];
|
||||||
|
for segment in path.get("segments")?.as_array()? {
|
||||||
|
let Some((kind, seg)) = variant(segment) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some((end, end_pressure)) = seg.get("end").and_then(element) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let controls: Vec<DVec2> = match kind {
|
||||||
|
"quadbezto" | "quadbez" => seg.get("cp").and_then(vec2).into_iter().collect(),
|
||||||
|
"cubbezto" | "cubbez" => [seg.get("cp1"), seg.get("cp2")]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|v| v.and_then(vec2))
|
||||||
|
.collect(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let new = bezier(prev, &controls, end);
|
||||||
|
let n = new.len() as f64;
|
||||||
|
for (i, p) in new.into_iter().enumerate() {
|
||||||
|
let t = (i + 1) as f64 / n;
|
||||||
|
points.push(p);
|
||||||
|
pressures.push(prev_pressure + (end_pressure - prev_pressure) * t);
|
||||||
|
}
|
||||||
|
prev = end;
|
||||||
|
prev_pressure = end_pressure;
|
||||||
|
}
|
||||||
|
let widths = pressures
|
||||||
|
.iter()
|
||||||
|
.map(|&p| curve_width(&style.pressure_curve, style.width, p))
|
||||||
|
.collect();
|
||||||
|
Some(RnoteItem::Ink {
|
||||||
|
highlighter: highlighter || color[3] < 0.99,
|
||||||
|
color,
|
||||||
|
points,
|
||||||
|
widths,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline of a shape as polylines.
|
||||||
|
fn shape_lines(shape: &Value) -> Option<Vec<Vec<DVec2>>> {
|
||||||
|
let (kind, s) = variant(shape)?;
|
||||||
|
let v = |k: &str| s.get(k).and_then(vec2);
|
||||||
|
let lines = match kind {
|
||||||
|
"line" => vec![vec![v("start")?, v("end")?]],
|
||||||
|
"arrow" => {
|
||||||
|
let (start, tip) = (v("start")?, v("tip")?);
|
||||||
|
let back = (start - tip).normalize_or_zero();
|
||||||
|
let length = (start.distance(tip) * 0.4).min(20.0);
|
||||||
|
let wing = |angle: f64| tip + DVec2::from_angle(angle).rotate(back) * length;
|
||||||
|
vec![vec![start, tip], vec![wing(0.5), tip, wing(-0.5)]]
|
||||||
|
}
|
||||||
|
"rect" => {
|
||||||
|
let half = vec2(s.get("cuboid")?.get("half_extents")?)?;
|
||||||
|
let affine = affine(s)?;
|
||||||
|
let corners = [
|
||||||
|
(-1.0, -1.0),
|
||||||
|
(1.0, -1.0),
|
||||||
|
(1.0, 1.0),
|
||||||
|
(-1.0, 1.0),
|
||||||
|
(-1.0, -1.0),
|
||||||
|
];
|
||||||
|
vec![corners
|
||||||
|
.iter()
|
||||||
|
.map(|&(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y)))
|
||||||
|
.collect()]
|
||||||
|
}
|
||||||
|
"ellipse" => {
|
||||||
|
let radii = v("radii")?;
|
||||||
|
let affine = affine(s)?;
|
||||||
|
vec![(0..=64)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||||||
|
apply(&affine, DVec2::new(a.cos() * radii.x, a.sin() * radii.y))
|
||||||
|
})
|
||||||
|
.collect()]
|
||||||
|
}
|
||||||
|
"quadbez" | "cubbez" => {
|
||||||
|
let start = v("start")?;
|
||||||
|
let controls: Vec<DVec2> = ["cp", "cp1", "cp2"].iter().filter_map(|k| v(k)).collect();
|
||||||
|
let mut points = vec![start];
|
||||||
|
points.extend(bezier(start, &controls, v("end")?));
|
||||||
|
vec![points]
|
||||||
|
}
|
||||||
|
"polyline" | "polygon" => {
|
||||||
|
let start = v("start")?;
|
||||||
|
let mut points = vec![start];
|
||||||
|
points.extend(s.get("path")?.as_array()?.iter().filter_map(vec2));
|
||||||
|
if kind == "polygon" {
|
||||||
|
points.push(start);
|
||||||
|
}
|
||||||
|
vec![points]
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shapestroke(value: &Value, highlighter: bool) -> Vec<RnoteItem> {
|
||||||
|
let Some(Style {
|
||||||
|
width,
|
||||||
|
stroke_color,
|
||||||
|
fill_color,
|
||||||
|
..
|
||||||
|
}) = style(value.get("style"))
|
||||||
|
else {
|
||||||
|
return vec![RnoteItem::Unsupported];
|
||||||
|
};
|
||||||
|
let (Some(color), Some(lines)) = (
|
||||||
|
stroke_color.or(fill_color),
|
||||||
|
value.get("shape").and_then(shape_lines),
|
||||||
|
) else {
|
||||||
|
return vec![RnoteItem::Unsupported];
|
||||||
|
};
|
||||||
|
lines
|
||||||
|
.into_iter()
|
||||||
|
.map(|points| RnoteItem::Ink {
|
||||||
|
color,
|
||||||
|
highlighter: highlighter || color[3] < 0.99,
|
||||||
|
widths: vec![width; points.len()],
|
||||||
|
points,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn textstroke(value: &Value) -> Option<RnoteItem> {
|
||||||
|
let style = value.get("text_style")?;
|
||||||
|
Some(RnoteItem::Text {
|
||||||
|
content: value.get("text")?.as_str()?.to_string(),
|
||||||
|
size: num(style, "font_size").unwrap_or(32.0).max(1.0),
|
||||||
|
mono: style
|
||||||
|
.get("font_family")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
|
||||||
|
affine: affine(value)?,
|
||||||
|
max_width: num(style, "max_width").filter(|w| *w > 0.0),
|
||||||
|
color: color(style.get("color")).unwrap_or([0.0, 0.0, 0.0, 1.0]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bitmapimage(value: &Value) -> Option<RnoteItem> {
|
||||||
|
let image = value.get("image")?;
|
||||||
|
let width = num(image, "pixel_width")? as u32;
|
||||||
|
let height = num(image, "pixel_height")? as u32;
|
||||||
|
let mut pixels = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(image.get("data")?.as_str()?)
|
||||||
|
.ok()?;
|
||||||
|
if pixels.len() != width as usize * height as usize * 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
import::unpremultiply(&mut pixels);
|
||||||
|
Some(RnoteItem::Bitmap {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels,
|
||||||
|
corners: rectangle_corners(value.get("rectangle")?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vectorimage(value: &Value) -> Option<RnoteItem> {
|
||||||
|
Some(RnoteItem::Svg {
|
||||||
|
svg: value.get("svg_data")?.as_str()?.to_string(),
|
||||||
|
corners: rectangle_corners(value.get("rectangle")?)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corners (upper left, upper right, lower right, lower left) of an Rnote rectangle: a
|
||||||
|
/// box around the origin, placed by its affine transformation.
|
||||||
|
fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
|
||||||
|
let half = vec2(rectangle.get("cuboid")?.get("half_extents")?)?;
|
||||||
|
let affine = affine(rectangle)?;
|
||||||
|
Some(
|
||||||
|
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||||
|
.map(|(x, y)| apply(&affine, DVec2::new(x * half.x, y * half.y))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rnote's version as (major, minor, patch).
|
||||||
|
fn version(text: &str) -> Option<(u64, u64, u64)> {
|
||||||
|
let v = semver::Version::parse(text).ok()?;
|
||||||
|
Some((v.major, v.minor, v.patch))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the (decompressed) JSON of an .rnote file.
|
||||||
|
pub fn parse(json: &[u8]) -> Result<RnoteDocument, String> {
|
||||||
|
let not_rnote = || t!("Keine Rnote-Datei").to_string();
|
||||||
|
let wrapper: Value = serde_json::from_slice(json).map_err(|_| not_rnote())?;
|
||||||
|
let version_text = wrapper
|
||||||
|
.get("version")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(not_rnote)?;
|
||||||
|
if version(version_text).is_none_or(|v| v < (0, 5, 10)) {
|
||||||
|
return Err(t!(
|
||||||
|
"Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern",
|
||||||
|
version = version_text
|
||||||
|
)
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
let snapshot = wrapper
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("engine_snapshot"))
|
||||||
|
.ok_or_else(not_rnote)?;
|
||||||
|
let document = snapshot.get("document").ok_or_else(not_rnote)?;
|
||||||
|
// Since Rnote 0.13 format, background and layout are in `config`
|
||||||
|
let config = document.get("config").unwrap_or(document);
|
||||||
|
let format = config.get("format").unwrap_or(&Value::Null);
|
||||||
|
let dpi = num(format, "dpi").filter(|d| *d > 0.0).unwrap_or(96.0);
|
||||||
|
let format_size = DVec2::new(
|
||||||
|
num(format, "width").unwrap_or(dpi * 210.0 / 25.4).max(1.0),
|
||||||
|
num(format, "height").unwrap_or(dpi * 297.0 / 25.4).max(1.0),
|
||||||
|
);
|
||||||
|
let background = match config
|
||||||
|
.get("background")
|
||||||
|
.and_then(|b| b.get("pattern"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
Some("lines") => PageBackground::Lined,
|
||||||
|
Some("grid" | "isometric_grid") => PageBackground::Grid5mm,
|
||||||
|
Some("dots" | "isometric_dots") => PageBackground::DotGrid,
|
||||||
|
_ => PageBackground::Blank,
|
||||||
|
};
|
||||||
|
let endless = matches!(
|
||||||
|
config.get("layout").and_then(Value::as_str),
|
||||||
|
Some("infinite" | "semi_infinite")
|
||||||
|
);
|
||||||
|
|
||||||
|
let strokes = snapshot
|
||||||
|
.get("stroke_components")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(not_rnote)?;
|
||||||
|
let chrono = snapshot
|
||||||
|
.get("chrono_components")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Slot maps are stored as slot arrays; the chrono components share the slot index
|
||||||
|
let mut ordered: Vec<(u64, Vec<RnoteItem>)> = Vec::new();
|
||||||
|
for (index, slot) in strokes.iter().enumerate() {
|
||||||
|
let Some(stroke) = slot.get("value").filter(|v| !v.is_null()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let chrono = chrono.get(index).and_then(|c| c.get("value"));
|
||||||
|
let t = chrono.and_then(|c| c.get("t")).and_then(Value::as_u64);
|
||||||
|
let highlighter = chrono
|
||||||
|
.and_then(|c| c.get("layer"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|l| l.eq_ignore_ascii_case("highlighter"));
|
||||||
|
let items = match variant(stroke) {
|
||||||
|
Some(("brushstroke", v)) => {
|
||||||
|
vec![brushstroke(v, highlighter).unwrap_or(RnoteItem::Unsupported)]
|
||||||
|
}
|
||||||
|
Some(("shapestroke", v)) => shapestroke(v, highlighter),
|
||||||
|
Some(("textstroke", v)) => vec![textstroke(v).unwrap_or(RnoteItem::Unsupported)],
|
||||||
|
Some(("bitmapimage", v)) => vec![bitmapimage(v).unwrap_or(RnoteItem::Unsupported)],
|
||||||
|
Some(("vectorimage", v)) => vec![vectorimage(v).unwrap_or(RnoteItem::Unsupported)],
|
||||||
|
_ => vec![RnoteItem::Unsupported],
|
||||||
|
};
|
||||||
|
ordered.push((t.unwrap_or(index as u64), items));
|
||||||
|
}
|
||||||
|
ordered.sort_by_key(|(t, _)| *t);
|
||||||
|
|
||||||
|
Ok(RnoteDocument {
|
||||||
|
format: format_size,
|
||||||
|
dpi,
|
||||||
|
origin: DVec2::new(
|
||||||
|
num(document, "x").unwrap_or(0.0),
|
||||||
|
num(document, "y").unwrap_or(0.0),
|
||||||
|
),
|
||||||
|
size: DVec2::new(
|
||||||
|
num(document, "width").unwrap_or(format_size.x),
|
||||||
|
num(document, "height").unwrap_or(format_size.y),
|
||||||
|
),
|
||||||
|
endless,
|
||||||
|
background,
|
||||||
|
items: ordered.into_iter().flat_map(|(_, items)| items).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new notebook from an Rnote file.
|
||||||
|
pub fn create_notebook_from_rnote(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, std::path::PathBuf, ImportReport), String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
let doc = parse(&import::gunzip(&bytes)?)?;
|
||||||
|
import::create_notebook(base_dir, title, |dir| Ok(convert(&doc, dir)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A target page: where its Rnote page starts, and how Rnote pixels map onto it.
|
||||||
|
struct Target {
|
||||||
|
rnote_origin: DVec2,
|
||||||
|
origin: glam::Vec2,
|
||||||
|
scale: f32,
|
||||||
|
size: glam::Vec2,
|
||||||
|
strokes: Vec<StrokeData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target {
|
||||||
|
fn map(&self, p: DVec2) -> glam::Vec2 {
|
||||||
|
self.origin + (p - self.rnote_origin).as_vec2() * self.scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
|
||||||
|
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||||
|
let mut report = ImportReport::default();
|
||||||
|
// Page units per Rnote pixel at the document's dpi
|
||||||
|
let units_per_px = (crate::document::UNITS_PER_MM as f64 * 25.4 / doc.dpi) as f32;
|
||||||
|
|
||||||
|
let (columns, mut targets) = if doc.endless {
|
||||||
|
let target = Target {
|
||||||
|
rnote_origin: DVec2::ZERO,
|
||||||
|
origin: crate::document::ENDLESS_HOME,
|
||||||
|
scale: units_per_px,
|
||||||
|
size: crate::document::ENDLESS_PAGE_SIZE,
|
||||||
|
strokes: Vec::new(),
|
||||||
|
};
|
||||||
|
(1, vec![target])
|
||||||
|
} else {
|
||||||
|
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||||
|
// aspect ratio, centred horizontally and aligned at the top
|
||||||
|
let size_pt = (doc.format / doc.dpi * 72.0).as_vec2();
|
||||||
|
let size = crate::document::pdf_page_size(size_pt.x, size_pt.y);
|
||||||
|
let [origin, top_right, _, bottom_left] =
|
||||||
|
crate::document::pdf_placement(size_pt.x, size_pt.y, size);
|
||||||
|
let scale_pt =
|
||||||
|
((top_right.x - origin.x) / size_pt.x).min((bottom_left.y - origin.y) / size_pt.y);
|
||||||
|
let scale = scale_pt * (72.0 / doc.dpi) as f32;
|
||||||
|
// Rnote grows documents page by page; tolerate rounding in the stored size
|
||||||
|
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
|
||||||
|
let columns = count(doc.size.x, doc.format.x);
|
||||||
|
let rows = count(doc.size.y, doc.format.y);
|
||||||
|
let rows = rows.min((MAX_PAGES / columns).max(1));
|
||||||
|
let targets = (0..rows * columns)
|
||||||
|
.map(|i| Target {
|
||||||
|
rnote_origin: doc.origin
|
||||||
|
+ DVec2::new((i % columns) as f64, (i / columns) as f64) * doc.format,
|
||||||
|
origin,
|
||||||
|
scale,
|
||||||
|
size,
|
||||||
|
strokes: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(columns, targets)
|
||||||
|
};
|
||||||
|
let rows = targets.len() / columns;
|
||||||
|
|
||||||
|
let mut next_id: u64 = 1;
|
||||||
|
for item in &doc.items {
|
||||||
|
let Some(anchor) = item.anchor() else {
|
||||||
|
report.skipped += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let cell = ((anchor - doc.origin) / doc.format).floor();
|
||||||
|
let index = if doc.endless {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
let column = (cell.x.max(0.0) as usize).min(columns - 1);
|
||||||
|
let row = (cell.y.max(0.0) as usize).min(rows - 1);
|
||||||
|
row * columns + column
|
||||||
|
};
|
||||||
|
let target = &mut targets[index];
|
||||||
|
let id = next_id;
|
||||||
|
next_id += 1;
|
||||||
|
let stroke = match item {
|
||||||
|
RnoteItem::Ink {
|
||||||
|
color,
|
||||||
|
highlighter,
|
||||||
|
points,
|
||||||
|
widths,
|
||||||
|
} => {
|
||||||
|
let points: Vec<glam::Vec2> = points.iter().map(|&p| target.map(p)).collect();
|
||||||
|
let widths: Vec<f32> = widths.iter().map(|&w| w as f32 * target.scale).collect();
|
||||||
|
let (width, pressures) = import::fit_widths(&widths);
|
||||||
|
import::ink_stroke(id, *color, *highlighter, &points, width, &pressures)
|
||||||
|
}
|
||||||
|
RnoteItem::Text {
|
||||||
|
content,
|
||||||
|
size,
|
||||||
|
mono,
|
||||||
|
affine,
|
||||||
|
max_width,
|
||||||
|
color,
|
||||||
|
} => {
|
||||||
|
let font = if *mono {
|
||||||
|
TextFont::Mono
|
||||||
|
} else {
|
||||||
|
TextFont::Sans
|
||||||
|
};
|
||||||
|
import::text_stroke(
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
*size as f32 * target.scale,
|
||||||
|
font,
|
||||||
|
target.map(affine[2]),
|
||||||
|
[affine[0].as_vec2(), affine[1].as_vec2()],
|
||||||
|
max_width.map(|w| w as f32 * target.scale),
|
||||||
|
*color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RnoteItem::Bitmap {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pixels,
|
||||||
|
corners,
|
||||||
|
} => match assets.import_rgba(*width, *height, pixels.clone()) {
|
||||||
|
Ok(image) => import::image_stroke(id, image.asset, corners.map(|c| target.map(c))),
|
||||||
|
Err(_) => {
|
||||||
|
report.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RnoteItem::Svg { svg, corners } => {
|
||||||
|
let corners = corners.map(|c| target.map(c));
|
||||||
|
let size = glam::Vec2::new(
|
||||||
|
corners[0].distance(corners[1]),
|
||||||
|
corners[0].distance(corners[3]),
|
||||||
|
);
|
||||||
|
let image = import::rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
|
||||||
|
assets.import_rgba(width, height, pixels).ok()
|
||||||
|
});
|
||||||
|
match image {
|
||||||
|
Some(image) => import::image_stroke(id, image.asset, corners),
|
||||||
|
None => {
|
||||||
|
report.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RnoteItem::Unsupported => unreachable!("has no anchor"),
|
||||||
|
};
|
||||||
|
target.strokes.push(stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages = targets
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, target)| {
|
||||||
|
import::page(
|
||||||
|
i as u64 + 1,
|
||||||
|
target.size,
|
||||||
|
doc.background,
|
||||||
|
None,
|
||||||
|
target.strokes,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Imported {
|
||||||
|
pages,
|
||||||
|
next_stroke_id: next_id,
|
||||||
|
layout: doc.endless.then_some(NotebookLayout::Endless),
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StrokeTool;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn slot(value: Value) -> Value {
|
||||||
|
json!({ "value": value, "version": 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small Rnote 0.15 document: two A4 pages at 96 dpi.
|
||||||
|
fn sample(layout: &str) -> Value {
|
||||||
|
let brush = json!({ "brushstroke": {
|
||||||
|
"path": {
|
||||||
|
"start": { "pos": [100.0, 100.0], "pressure": 0.5 },
|
||||||
|
"segments": [
|
||||||
|
{ "lineto": { "end": { "pos": [120.0, 100.0], "pressure": 1.0 } } },
|
||||||
|
{ "cubbezto": { "cp1": [130.0, 110.0], "cp2": [140.0, 110.0],
|
||||||
|
"end": { "pos": [150.0, 100.0], "pressure": 0.25 } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"style": { "smooth": { "stroke_width": 4.0,
|
||||||
|
"stroke_color": { "r": 0.0, "g": 0.0, "b": 1.0, "a": 1.0 },
|
||||||
|
"fill_color": null, "pressure_curve": "linear" } }
|
||||||
|
}});
|
||||||
|
let rect = json!({ "shapestroke": {
|
||||||
|
"shape": { "rect": { "cuboid": { "half_extents": [50.0, 20.0] },
|
||||||
|
"affine": [1.0, 0.0, 0.0, 1.0, 300.0, 1300.0] } },
|
||||||
|
"style": { "rough": { "stroke_width": 2.0,
|
||||||
|
"stroke_color": { "r": 1.0, "g": 0.0, "b": 0.0, "a": 1.0 } } }
|
||||||
|
}});
|
||||||
|
let text = json!({ "textstroke": {
|
||||||
|
"text": "Hallo",
|
||||||
|
"affine": [1.0, 0.0, 0.0, 1.0, 50.0, 60.0],
|
||||||
|
"text_style": { "font_family": "serif", "font_size": 32.0, "max_width": null,
|
||||||
|
"color": { "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 } }
|
||||||
|
}});
|
||||||
|
let pixels = base64::engine::general_purpose::STANDARD.encode([128u8, 0, 0, 128].repeat(4));
|
||||||
|
let bitmap = json!({ "bitmapimage": {
|
||||||
|
"image": { "data": pixels, "pixel_width": 2, "pixel_height": 2,
|
||||||
|
"memory_format": "R8g8b8a8Premultiplied" },
|
||||||
|
"rectangle": { "cuboid": { "half_extents": [10.0, 10.0] },
|
||||||
|
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 400.0] }
|
||||||
|
}});
|
||||||
|
let svg = json!({ "vectorimage": {
|
||||||
|
"svg_data": "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><rect width='10' height='10' fill='green'/></svg>",
|
||||||
|
"intrinsic_size": [10.0, 10.0],
|
||||||
|
"rectangle": { "cuboid": { "half_extents": [40.0, 40.0] },
|
||||||
|
"affine": [1.0, 0.0, 0.0, 1.0, 400.0, 1500.0] }
|
||||||
|
}});
|
||||||
|
let marker = json!({ "brushstroke": {
|
||||||
|
"path": { "start": { "pos": [10.0, 10.0], "pressure": 1.0 },
|
||||||
|
"segments": [ { "lineto": { "end": { "pos": [90.0, 10.0], "pressure": 1.0 } } } ] },
|
||||||
|
"style": { "smooth": { "stroke_width": 12.0,
|
||||||
|
"stroke_color": { "r": 1.0, "g": 1.0, "b": 0.0, "a": 1.0 },
|
||||||
|
"fill_color": null, "pressure_curve": "const" } }
|
||||||
|
}});
|
||||||
|
let chrono = |t: u32, layer: &str| slot(json!({ "t": t, "layer": layer }));
|
||||||
|
json!({
|
||||||
|
"version": "0.15.0",
|
||||||
|
"data": { "engine_snapshot": {
|
||||||
|
"document": {
|
||||||
|
"config": {
|
||||||
|
"format": { "width": 793.701, "height": 1122.52, "dpi": 96.0 },
|
||||||
|
"background": { "pattern": "grid" },
|
||||||
|
"layout": layout
|
||||||
|
},
|
||||||
|
"x": 0.0, "y": 0.0, "width": 793.701, "height": 2245.04
|
||||||
|
},
|
||||||
|
"camera": {},
|
||||||
|
// Slot 0 is the slot map's unused sentinel; the text was drawn first
|
||||||
|
"stroke_components": [
|
||||||
|
{ "value": null, "version": 0 },
|
||||||
|
slot(brush), slot(rect), slot(text), slot(bitmap), slot(svg), slot(marker)
|
||||||
|
],
|
||||||
|
"chrono_components": [
|
||||||
|
{ "value": null, "version": 0 },
|
||||||
|
chrono(2, "user_layer"), chrono(3, "user_layer"), chrono(1, "user_layer"),
|
||||||
|
chrono(4, "image"), chrono(5, "image"), chrono(6, "highlighter")
|
||||||
|
],
|
||||||
|
"chrono_counter": 7
|
||||||
|
}}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_rnote(dir: &Path, value: &Value) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("Skizze.rnote");
|
||||||
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
encoder.write_all(value.to_string().as_bytes()).unwrap();
|
||||||
|
std::fs::write(&path, encoder.finish().unwrap()).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rnote() {
|
||||||
|
let doc = parse(sample("fixed_size").to_string().as_bytes()).unwrap();
|
||||||
|
assert_eq!(doc.background, PageBackground::Grid5mm);
|
||||||
|
assert!(!doc.endless);
|
||||||
|
// In drawing order: the text was drawn first
|
||||||
|
assert!(matches!(doc.items[0], RnoteItem::Text { .. }));
|
||||||
|
match &doc.items[1] {
|
||||||
|
RnoteItem::Ink {
|
||||||
|
points,
|
||||||
|
widths,
|
||||||
|
color,
|
||||||
|
highlighter,
|
||||||
|
} => {
|
||||||
|
assert!(!highlighter);
|
||||||
|
assert_eq!(*color, [0.0, 0.0, 1.0, 1.0]);
|
||||||
|
assert_eq!(points.first(), Some(&DVec2::new(100.0, 100.0)));
|
||||||
|
assert_eq!(points.last(), Some(&DVec2::new(150.0, 100.0)));
|
||||||
|
assert!(points.len() > 3, "the curve is sampled");
|
||||||
|
assert_eq!(widths[0], 2.0);
|
||||||
|
assert_eq!(widths[1], 4.0);
|
||||||
|
assert_eq!(*widths.last().unwrap(), 1.0);
|
||||||
|
}
|
||||||
|
other => panic!("expected ink, got {other:?}"),
|
||||||
|
}
|
||||||
|
match &doc.items[2] {
|
||||||
|
RnoteItem::Ink { points, .. } => {
|
||||||
|
assert_eq!(points.len(), 5);
|
||||||
|
assert_eq!(points[0], DVec2::new(250.0, 1280.0));
|
||||||
|
assert_eq!(points[2], DVec2::new(350.0, 1320.0));
|
||||||
|
}
|
||||||
|
other => panic!("expected the rectangle, got {other:?}"),
|
||||||
|
}
|
||||||
|
match &doc.items[3] {
|
||||||
|
RnoteItem::Bitmap { pixels, .. } => assert_eq!(&pixels[..4], &[255, 0, 0, 128]),
|
||||||
|
other => panic!("expected a bitmap, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(doc.items[4], RnoteItem::Svg { .. }));
|
||||||
|
assert!(matches!(
|
||||||
|
doc.items[5],
|
||||||
|
RnoteItem::Ink {
|
||||||
|
highlighter: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_older_formats() {
|
||||||
|
// Before 0.13: format, background and layout directly in the document; before 0.15:
|
||||||
|
// transformations as column-major 3×3 matrix
|
||||||
|
let mut value = sample("continuous_vertical");
|
||||||
|
value["version"] = json!("0.9.4");
|
||||||
|
let document = &mut value["data"]["engine_snapshot"]["document"];
|
||||||
|
let config = document["config"].take();
|
||||||
|
for key in ["format", "background", "layout"] {
|
||||||
|
document[key] = config[key].clone();
|
||||||
|
}
|
||||||
|
document.as_object_mut().unwrap().remove("config");
|
||||||
|
let text =
|
||||||
|
&mut value["data"]["engine_snapshot"]["stroke_components"][3]["value"]["textstroke"];
|
||||||
|
text.as_object_mut().unwrap().remove("affine");
|
||||||
|
text["transform"] = json!({ "affine": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 50.0, 60.0, 1.0] });
|
||||||
|
let doc = parse(value.to_string().as_bytes()).unwrap();
|
||||||
|
assert_eq!(doc.background, PageBackground::Grid5mm);
|
||||||
|
match &doc.items[0] {
|
||||||
|
RnoteItem::Text { affine, .. } => assert_eq!(affine[2], DVec2::new(50.0, 60.0)),
|
||||||
|
other => panic!("expected a text, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
value["version"] = json!("0.5.5");
|
||||||
|
assert!(parse(value.to_string().as_bytes()).is_err());
|
||||||
|
assert!(parse(b"{\"a\": 1}").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_notebook_from_rnote() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = write_rnote(source_dir.path(), &sample("fixed_size"));
|
||||||
|
let (notebook, manifest, dir, report) =
|
||||||
|
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
|
||||||
|
assert_eq!(report, ImportReport::default());
|
||||||
|
assert_eq!(manifest.layout, NotebookManifest::new("x", "x").layout);
|
||||||
|
|
||||||
|
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
|
||||||
|
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
let [first, second] = &reloaded.pages[..] else {
|
||||||
|
panic!("two pages expected");
|
||||||
|
};
|
||||||
|
assert_eq!(first.background, PageBackground::Grid5mm);
|
||||||
|
// Text, pen, bitmap, highlighter on page 1; rectangle and SVG on page 2
|
||||||
|
assert_eq!(first.strokes.len(), 4);
|
||||||
|
assert_eq!(second.strokes.len(), 2);
|
||||||
|
assert!(first.strokes[0].is_text());
|
||||||
|
let pen = &first.strokes[1];
|
||||||
|
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||||
|
// 96 dpi: 100 px = 26.46 mm
|
||||||
|
assert!((pen.points[0].position.x - 264.6).abs() < 0.5);
|
||||||
|
assert!(first.strokes[2].is_image());
|
||||||
|
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
|
||||||
|
// The rectangle sits on page 2 in page coordinates: 250 px - 0 / 1280 px - 1122.52 px
|
||||||
|
let rect = &second.strokes[0];
|
||||||
|
assert!((rect.points[0].position.x - 661.5).abs() < 0.5);
|
||||||
|
assert!((rect.points[0].position.y - 416.7).abs() < 0.5);
|
||||||
|
assert!(second.strokes[1].is_image());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_infinite_documents_become_endless() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = write_rnote(source_dir.path(), &sample("infinite"));
|
||||||
|
let (notebook, manifest, _, _) =
|
||||||
|
create_notebook_from_rnote(temp_dir.path(), "Skizze", &path).unwrap();
|
||||||
|
assert_eq!(manifest.layout, Some(NotebookLayout::Endless));
|
||||||
|
assert_eq!(notebook.pages.len(), 1);
|
||||||
|
assert!(notebook.pages[0].is_endless());
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 6);
|
||||||
|
let pen = ¬ebook.pages[0].strokes[1];
|
||||||
|
let home = crate::document::ENDLESS_HOME;
|
||||||
|
assert!((pen.points[0].position.x - home.x - 264.6).abs() < 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
+904
@@ -0,0 +1,904 @@
|
|||||||
|
//! Import of Saber notebooks (.sbn2, .sba, old .sbn) (Issue #47).
|
||||||
|
//!
|
||||||
|
//! A `.sbn2` file is a BSON document; images and PDFs lie next to it as `<file>.sbn2.0`,
|
||||||
|
//! `<file>.sbn2.1`, …; a `.sba` archive is a zip with `main.sbn2` and its assets, and the old
|
||||||
|
//! `.sbn` is the same document as JSON. Pages (`z`) hold strokes (`s`), images (`i`), typed
|
||||||
|
//! text as a Quill delta (`q`) and an optional background image or PDF page (`b`). Points are
|
||||||
|
//! three little-endian `f32` (x, y, pressure) since format version 13.
|
||||||
|
//!
|
||||||
|
//! Stroke widths follow perfect_freehand: the diameter is `size · (1 − thinning · (1 − 2p))`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::document::{Notebook, PageBackground, PdfBackground};
|
||||||
|
use crate::import::{self, ImportReport, Imported};
|
||||||
|
use crate::storage::{NotebookManifest, StrokeData};
|
||||||
|
use crate::t;
|
||||||
|
use crate::text::TextFont;
|
||||||
|
|
||||||
|
/// Saber's default page size, used when a page has none.
|
||||||
|
const DEFAULT_PAGE: glam::Vec2 = glam::Vec2::new(1000.0, 1400.0);
|
||||||
|
|
||||||
|
/// Is the file by its name a Saber notebook?
|
||||||
|
pub fn is_saber_path(path: &Path) -> bool {
|
||||||
|
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||||
|
["sbn2", "sba", "sbn"]
|
||||||
|
.iter()
|
||||||
|
.any(|x| e.eq_ignore_ascii_case(x))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A BSON (or JSON) value as far as Saber uses it.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Bson {
|
||||||
|
Null,
|
||||||
|
Bool(bool),
|
||||||
|
Int(i64),
|
||||||
|
Double(f64),
|
||||||
|
Str(String),
|
||||||
|
Binary(Vec<u8>),
|
||||||
|
Array(Vec<Bson>),
|
||||||
|
Doc(Vec<(String, Bson)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bson {
|
||||||
|
pub fn get(&self, key: &str) -> Option<&Bson> {
|
||||||
|
match self {
|
||||||
|
Bson::Doc(fields) => fields
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == key)
|
||||||
|
.map(|(_, v)| v)
|
||||||
|
.filter(|v| **v != Bson::Null),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
|
match *self {
|
||||||
|
Bson::Double(v) if v.is_finite() => Some(v),
|
||||||
|
Bson::Int(v) => Some(v as f64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_i64(&self) -> Option<i64> {
|
||||||
|
match *self {
|
||||||
|
Bson::Int(v) => Some(v),
|
||||||
|
Bson::Double(v) if v.is_finite() => Some(v as i64),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Bson::Str(s) => Some(s),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_array(&self) -> &[Bson] {
|
||||||
|
match self {
|
||||||
|
Bson::Array(items) => items,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(&self, key: &str) -> Option<f64> {
|
||||||
|
self.get(key)?.as_f64()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool(&self, key: &str) -> Option<bool> {
|
||||||
|
match self.get(key)? {
|
||||||
|
Bson::Bool(b) => Some(*b),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_json(value: serde_json::Value) -> Bson {
|
||||||
|
use serde_json::Value;
|
||||||
|
match value {
|
||||||
|
Value::Null => Bson::Null,
|
||||||
|
Value::Bool(b) => Bson::Bool(b),
|
||||||
|
Value::Number(n) => n
|
||||||
|
.as_i64()
|
||||||
|
.map(Bson::Int)
|
||||||
|
.unwrap_or_else(|| Bson::Double(n.as_f64().unwrap_or(0.0))),
|
||||||
|
Value::String(s) => Bson::Str(s),
|
||||||
|
Value::Array(items) => Bson::Array(items.into_iter().map(Bson::from_json).collect()),
|
||||||
|
Value::Object(map) => Bson::Doc(
|
||||||
|
map.into_iter()
|
||||||
|
.map(|(k, v)| (k, Bson::from_json(v)))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a BSON document.
|
||||||
|
pub fn parse_bson(bytes: &[u8]) -> Result<Bson, String> {
|
||||||
|
let mut pos = 0;
|
||||||
|
document(bytes, &mut pos, 0).ok_or_else(|| t!("Keine Saber-Datei").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take<'a>(bytes: &'a [u8], pos: &mut usize, n: usize) -> Option<&'a [u8]> {
|
||||||
|
let slice = bytes.get(*pos..pos.checked_add(n)?)?;
|
||||||
|
*pos += n;
|
||||||
|
Some(slice)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn int32(bytes: &[u8], pos: &mut usize) -> Option<i32> {
|
||||||
|
Some(i32::from_le_bytes(take(bytes, pos, 4)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cstring(bytes: &[u8], pos: &mut usize) -> Option<String> {
|
||||||
|
let len = bytes.get(*pos..)?.iter().position(|&b| b == 0)?;
|
||||||
|
let s = String::from_utf8_lossy(take(bytes, pos, len)?).into_owned();
|
||||||
|
*pos += 1;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document (or array, which is a document with the keys "0", "1", …).
|
||||||
|
fn document(bytes: &[u8], pos: &mut usize, depth: usize) -> Option<Bson> {
|
||||||
|
if depth > 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = *pos;
|
||||||
|
let len = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||||
|
let end = start.checked_add(len)?;
|
||||||
|
if end > bytes.len() || len < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
loop {
|
||||||
|
let kind = *bytes.get(*pos)?;
|
||||||
|
*pos += 1;
|
||||||
|
if kind == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let key = cstring(bytes, pos)?;
|
||||||
|
let value = match kind {
|
||||||
|
0x01 => Bson::Double(f64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?)),
|
||||||
|
0x02 => {
|
||||||
|
let n = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||||
|
let s = take(bytes, pos, n)?;
|
||||||
|
Bson::Str(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned())
|
||||||
|
}
|
||||||
|
0x03 => document(bytes, pos, depth + 1)?,
|
||||||
|
0x04 => match document(bytes, pos, depth + 1)? {
|
||||||
|
Bson::Doc(items) => Bson::Array(items.into_iter().map(|(_, v)| v).collect()),
|
||||||
|
other => other,
|
||||||
|
},
|
||||||
|
0x05 => {
|
||||||
|
let n = usize::try_from(int32(bytes, pos)?).ok()?;
|
||||||
|
*pos += 1; // subtype
|
||||||
|
Bson::Binary(take(bytes, pos, n)?.to_vec())
|
||||||
|
}
|
||||||
|
0x07 => {
|
||||||
|
take(bytes, pos, 12)?;
|
||||||
|
Bson::Null
|
||||||
|
}
|
||||||
|
0x08 => Bson::Bool(*take(bytes, pos, 1)?.first()? != 0),
|
||||||
|
0x09 | 0x11 | 0x12 => {
|
||||||
|
Bson::Int(i64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?))
|
||||||
|
}
|
||||||
|
0x0A | 0x06 | 0x7F | 0xFF => Bson::Null,
|
||||||
|
0x10 => Bson::Int(int32(bytes, pos)? as i64),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
fields.push((key, value));
|
||||||
|
}
|
||||||
|
(*pos == end).then_some(Bson::Doc(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the files of a zip archive (stored or deflated), at most `limit` bytes in total.
|
||||||
|
fn unzip(bytes: &[u8], limit: usize) -> Result<HashMap<String, Vec<u8>>, String> {
|
||||||
|
let broken = || t!("Datei beschädigt: {err}", err = "zip").to_string();
|
||||||
|
let u16_at = |at: usize| -> Option<usize> {
|
||||||
|
Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?) as usize)
|
||||||
|
};
|
||||||
|
let u32_at = |at: usize| -> Option<usize> {
|
||||||
|
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize)
|
||||||
|
};
|
||||||
|
// End of central directory: the last record with its signature
|
||||||
|
let eocd = (0..bytes.len().saturating_sub(21))
|
||||||
|
.rev()
|
||||||
|
.find(|&i| bytes[i..].starts_with(&[0x50, 0x4b, 0x05, 0x06]))
|
||||||
|
.ok_or_else(broken)?;
|
||||||
|
let count = u16_at(eocd + 10).ok_or_else(broken)?;
|
||||||
|
let mut at = u32_at(eocd + 16).ok_or_else(broken)?;
|
||||||
|
let mut files = HashMap::new();
|
||||||
|
let mut total = 0;
|
||||||
|
for _ in 0..count {
|
||||||
|
let entry = (|| {
|
||||||
|
if !bytes.get(at..)?.starts_with(&[0x50, 0x4b, 0x01, 0x02]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let method = u16_at(at + 10)?;
|
||||||
|
let compressed = u32_at(at + 20)?;
|
||||||
|
let size = u32_at(at + 24)?;
|
||||||
|
let name_len = u16_at(at + 28)?;
|
||||||
|
let skip = name_len + u16_at(at + 30)? + u16_at(at + 32)?;
|
||||||
|
let local = u32_at(at + 42)?;
|
||||||
|
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + name_len)?);
|
||||||
|
at += 46 + skip;
|
||||||
|
let data_start = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?;
|
||||||
|
let data = bytes.get(data_start..data_start.checked_add(compressed)?)?;
|
||||||
|
Some((name.into_owned(), method, size, data))
|
||||||
|
})();
|
||||||
|
let (name, method, size, data) = entry.ok_or_else(broken)?;
|
||||||
|
total += size;
|
||||||
|
if total > limit {
|
||||||
|
return Err(broken());
|
||||||
|
}
|
||||||
|
let content = match method {
|
||||||
|
0 => data.to_vec(),
|
||||||
|
8 => {
|
||||||
|
let mut out = Vec::with_capacity(size);
|
||||||
|
flate2::read::DeflateDecoder::new(data)
|
||||||
|
.take(size as u64)
|
||||||
|
.read_to_end(&mut out)
|
||||||
|
.map_err(|_| broken())?;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
_ => return Err(broken()),
|
||||||
|
};
|
||||||
|
files.insert(name, content);
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document and a way to get its assets (by index).
|
||||||
|
struct Source {
|
||||||
|
doc: Bson,
|
||||||
|
assets: Box<dyn Fn(usize) -> Option<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(path: &Path) -> Result<Source, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
if bytes.starts_with(&[0x50, 0x4b, 0x03, 0x04]) {
|
||||||
|
let files = unzip(&bytes, 1024 * 1024 * 1024)?;
|
||||||
|
let main = files
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name.ends_with(".sbn2"))
|
||||||
|
.map(|(name, data)| (name.clone(), data))
|
||||||
|
.ok_or_else(|| t!("Keine Saber-Datei").to_string())?;
|
||||||
|
let doc = parse_bson(main.1)?;
|
||||||
|
let prefix = main.0;
|
||||||
|
return Ok(Source {
|
||||||
|
doc,
|
||||||
|
assets: Box::new(move |i| files.get(&format!("{prefix}.{i}")).cloned()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// .sbn2 is BSON, the old .sbn JSON
|
||||||
|
let doc = match parse_bson(&bytes) {
|
||||||
|
Ok(doc) => doc,
|
||||||
|
Err(err) => serde_json::from_slice(&bytes)
|
||||||
|
.map(Bson::from_json)
|
||||||
|
.map_err(|_| err)?,
|
||||||
|
};
|
||||||
|
let base = path.to_path_buf();
|
||||||
|
Ok(Source {
|
||||||
|
doc,
|
||||||
|
assets: Box::new(move |i| {
|
||||||
|
std::fs::read(PathBuf::from(format!("{}.{i}", base.display()))).ok()
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color stored as ARGB integer.
|
||||||
|
fn argb(value: Option<&Bson>) -> [f32; 4] {
|
||||||
|
let v = value.and_then(Bson::as_i64).unwrap_or(0xff000000) as u32;
|
||||||
|
[
|
||||||
|
((v >> 16) & 0xff) as f32 / 255.0,
|
||||||
|
((v >> 8) & 0xff) as f32 / 255.0,
|
||||||
|
(v & 0xff) as f32 / 255.0,
|
||||||
|
(v >> 24) as f32 / 255.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points of a stroke: `f32` triples (version 13 on) or `{x, y, p}` documents.
|
||||||
|
fn points(stroke: &Bson) -> Vec<(glam::Vec2, Option<f32>)> {
|
||||||
|
let offset = glam::Vec2::new(
|
||||||
|
stroke.num("ox").unwrap_or(0.0) as f32,
|
||||||
|
stroke.num("oy").unwrap_or(0.0) as f32,
|
||||||
|
);
|
||||||
|
let Some(list) = stroke.get("p") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
list.as_array()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|point| match point {
|
||||||
|
Bson::Binary(data) => {
|
||||||
|
let values: Vec<f32> = data
|
||||||
|
.as_chunks::<4>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|b| f32::from_le_bytes(*b))
|
||||||
|
.collect();
|
||||||
|
match values[..] {
|
||||||
|
[x, y] => Some((glam::Vec2::new(x, y), None)),
|
||||||
|
[x, y, p, ..] => Some((glam::Vec2::new(x, y), Some(p))),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc @ Bson::Doc(_) => Some((
|
||||||
|
glam::Vec2::new(doc.num("x")? as f32, doc.num("y")? as f32),
|
||||||
|
doc.num("p").map(|p| p as f32),
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|(p, _)| p.is_finite())
|
||||||
|
.map(|(p, pressure)| (p + offset, pressure))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline points of a shape stroke (circle or rectangle).
|
||||||
|
fn shape_points(stroke: &Bson, shape: &str) -> Vec<glam::Vec2> {
|
||||||
|
let n = |k| stroke.num(k).unwrap_or(0.0) as f32;
|
||||||
|
match shape {
|
||||||
|
"circle" => {
|
||||||
|
let (center, r) = (glam::Vec2::new(n("cx"), n("cy")), n("r"));
|
||||||
|
(0..=64)
|
||||||
|
.map(|i| {
|
||||||
|
let a = i as f32 / 64.0 * std::f32::consts::TAU;
|
||||||
|
center + glam::Vec2::new(a.cos(), a.sin()) * r
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
"rect" => {
|
||||||
|
let (l, t, w, h) = (n("rl"), n("rt"), n("rw"), n("rh"));
|
||||||
|
[(l, t), (l + w, t), (l + w, t + h), (l, t + h), (l, t)]
|
||||||
|
.map(|(x, y)| glam::Vec2::new(x, y))
|
||||||
|
.to_vec()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed text of a page: the inserted strings of its Quill delta.
|
||||||
|
fn quill_text(delta: &Bson) -> String {
|
||||||
|
let text: String = delta
|
||||||
|
.as_array()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|op| op.get("insert")?.as_str())
|
||||||
|
.collect();
|
||||||
|
text.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new notebook from a Saber file.
|
||||||
|
pub fn create_notebook_from_saber(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||||
|
let source = load(path)?;
|
||||||
|
// The very first files were only a list of strokes; versions up to 7 kept strokes and
|
||||||
|
// images outside the pages, which is read as well
|
||||||
|
if !matches!(source.doc, Bson::Doc(_)) {
|
||||||
|
return Err(t!(
|
||||||
|
"Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern"
|
||||||
|
)
|
||||||
|
.to_string());
|
||||||
|
}
|
||||||
|
import::create_notebook(base_dir, title, |dir| Ok(convert(&source, dir)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PDF of the notebook in our asset folder.
|
||||||
|
struct PdfSource {
|
||||||
|
asset: String,
|
||||||
|
sizes: Vec<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(source: &Source, notebook_dir: &Path) -> Imported {
|
||||||
|
let doc = &source.doc;
|
||||||
|
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||||
|
let mut report = ImportReport::default();
|
||||||
|
let mut pdfs: HashMap<usize, Option<PdfSource>> = HashMap::new();
|
||||||
|
let background = match doc.get("p").and_then(Bson::as_str) {
|
||||||
|
Some("lined" | "college" | "college-rtl" | "cornell") => PageBackground::Lined,
|
||||||
|
Some("grid") => PageBackground::Grid5mm,
|
||||||
|
Some("dots") => PageBackground::DotGrid,
|
||||||
|
_ => PageBackground::Blank,
|
||||||
|
};
|
||||||
|
let line_height = doc.num("l").unwrap_or(40.0) as f32;
|
||||||
|
let mut xpages: Vec<&Bson> = doc
|
||||||
|
.get("z")
|
||||||
|
.map(Bson::as_array)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.collect();
|
||||||
|
let empty = Bson::Doc(Vec::new());
|
||||||
|
if xpages.is_empty() {
|
||||||
|
xpages.push(&empty);
|
||||||
|
}
|
||||||
|
// Before version 8 strokes and images were stored outside the pages, with a page index
|
||||||
|
let old_items = |key: &str, index: usize| -> Vec<&Bson> {
|
||||||
|
doc.get(key)
|
||||||
|
.map(Bson::as_array)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.num("i").unwrap_or(0.0) as usize == index)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut next_id: u64 = 1;
|
||||||
|
let mut new_id = || {
|
||||||
|
next_id += 1;
|
||||||
|
next_id - 1
|
||||||
|
};
|
||||||
|
let mut pages = Vec::with_capacity(xpages.len());
|
||||||
|
for (index, xpage) in xpages.iter().enumerate() {
|
||||||
|
// Pages stored as [width, height] in very old files
|
||||||
|
let page_size = match xpage {
|
||||||
|
Bson::Array(wh) => glam::Vec2::new(
|
||||||
|
wh.first().and_then(Bson::as_f64).unwrap_or(1000.0) as f32,
|
||||||
|
wh.get(1).and_then(Bson::as_f64).unwrap_or(1400.0) as f32,
|
||||||
|
),
|
||||||
|
_ => glam::Vec2::new(
|
||||||
|
xpage.num("w").map_or(DEFAULT_PAGE.x, |w| w as f32),
|
||||||
|
xpage.num("h").map_or(DEFAULT_PAGE.y, |h| h as f32),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.max(glam::Vec2::ONE);
|
||||||
|
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||||
|
// aspect ratio, centred horizontally and aligned at the top
|
||||||
|
let size = crate::document::pdf_page_size(page_size.x, page_size.y);
|
||||||
|
let placement = crate::document::pdf_placement(page_size.x, page_size.y, size);
|
||||||
|
let scale = (placement[1].x - placement[0].x) / page_size.x;
|
||||||
|
let map = |p: glam::Vec2| placement[0] + p * scale;
|
||||||
|
|
||||||
|
let mut strokes: Vec<StrokeData> = Vec::new();
|
||||||
|
let mut pdf_background = None;
|
||||||
|
|
||||||
|
if let Some(image) = xpage.get("b") {
|
||||||
|
let natural = glam::Vec2::new(
|
||||||
|
image.num("nw").unwrap_or(0.0) as f32,
|
||||||
|
image.num("nh").unwrap_or(0.0) as f32,
|
||||||
|
);
|
||||||
|
let corners = if natural.min_element() > 0.0 {
|
||||||
|
crate::document::pdf_placement(natural.x, natural.y, size)
|
||||||
|
} else {
|
||||||
|
placement
|
||||||
|
};
|
||||||
|
let asset = image.num("a").map(|a| a as usize);
|
||||||
|
if image.get("e").and_then(Bson::as_str) == Some(".pdf") {
|
||||||
|
let pdf = asset.and_then(|a| {
|
||||||
|
pdfs.entry(a)
|
||||||
|
.or_insert_with(|| {
|
||||||
|
let bytes = (source.assets)(a)?;
|
||||||
|
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
|
||||||
|
let asset = assets.import_pdf(&bytes).ok()?;
|
||||||
|
Some(PdfSource { asset, sizes })
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
});
|
||||||
|
let page = image.num("pdfi").unwrap_or(0.0) as u32;
|
||||||
|
match pdf.and_then(|pdf| Some((pdf, *pdf.sizes.get(page as usize)?))) {
|
||||||
|
Some((pdf, (width_pt, height_pt))) => {
|
||||||
|
pdf_background = Some(PdfBackground {
|
||||||
|
asset: pdf.asset.clone(),
|
||||||
|
page,
|
||||||
|
width_pt,
|
||||||
|
height_pt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match image_asset(image, source, &assets, corners) {
|
||||||
|
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let images = xpage.get("i").map(Bson::as_array).unwrap_or_default();
|
||||||
|
for image in images.iter().chain(old_items("i", index)) {
|
||||||
|
let rect = ["x", "y", "w", "h"].map(|k| image.num(k).unwrap_or(0.0) as f32);
|
||||||
|
let corners = [
|
||||||
|
glam::Vec2::new(rect[0], rect[1]),
|
||||||
|
glam::Vec2::new(rect[0] + rect[2], rect[1]),
|
||||||
|
glam::Vec2::new(rect[0] + rect[2], rect[1] + rect[3]),
|
||||||
|
glam::Vec2::new(rect[0], rect[1] + rect[3]),
|
||||||
|
]
|
||||||
|
.map(map);
|
||||||
|
match image_asset(image, source, &assets, corners) {
|
||||||
|
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(delta) = xpage.get("q") {
|
||||||
|
let text = quill_text(delta);
|
||||||
|
if !text.is_empty() {
|
||||||
|
// Saber's text area starts 1.2 lines down, half a line in from the sides
|
||||||
|
let origin = glam::Vec2::new(line_height * 0.5, line_height * 1.2);
|
||||||
|
strokes.push(import::text_stroke(
|
||||||
|
new_id(),
|
||||||
|
&text,
|
||||||
|
line_height * 0.8 * scale,
|
||||||
|
TextFont::Sans,
|
||||||
|
map(origin),
|
||||||
|
[glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
Some((page_size.x - line_height) * scale),
|
||||||
|
[0.0, 0.0, 0.0, 1.0],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_strokes = xpage.get("s").map(Bson::as_array).unwrap_or_default();
|
||||||
|
for stroke in page_strokes.iter().chain(old_items("s", index)) {
|
||||||
|
let tool = stroke
|
||||||
|
.get("ty")
|
||||||
|
.and_then(Bson::as_str)
|
||||||
|
.unwrap_or("fountainPen");
|
||||||
|
if tool == "Eraser" || tool == "LaserPointer" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let size = stroke.num("s").unwrap_or(10.0) as f32;
|
||||||
|
let thinning = stroke.num("t").unwrap_or(0.5) as f32;
|
||||||
|
let pressure_enabled = stroke.bool("pe").unwrap_or(true);
|
||||||
|
let (points, pressures): (Vec<glam::Vec2>, Vec<Option<f32>>) =
|
||||||
|
match stroke.get("shape").and_then(Bson::as_str) {
|
||||||
|
Some(shape) => shape_points(stroke, shape)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p, None))
|
||||||
|
.unzip(),
|
||||||
|
None => points(stroke).into_iter().unzip(),
|
||||||
|
};
|
||||||
|
if points.is_empty() {
|
||||||
|
report.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let widths: Vec<f32> = pressures
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let p = p
|
||||||
|
.filter(|_| pressure_enabled)
|
||||||
|
.unwrap_or(0.5)
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
(size * (1.0 - thinning * (1.0 - 2.0 * p))).max(size * 0.05) * scale
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let (width, pressures) = import::fit_widths(&widths);
|
||||||
|
let points: Vec<glam::Vec2> = points.into_iter().map(map).collect();
|
||||||
|
strokes.push(import::ink_stroke(
|
||||||
|
new_id(),
|
||||||
|
argb(stroke.get("c")),
|
||||||
|
tool == "Highlighter",
|
||||||
|
&points,
|
||||||
|
width,
|
||||||
|
&pressures,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(import::page(
|
||||||
|
index as u64 + 1,
|
||||||
|
size,
|
||||||
|
background,
|
||||||
|
pdf_background,
|
||||||
|
strokes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Imported {
|
||||||
|
pages,
|
||||||
|
next_stroke_id: next_id,
|
||||||
|
layout: None,
|
||||||
|
report,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the image of `image` (asset file, inline bytes or SVG text) in our asset folder.
|
||||||
|
fn image_asset(
|
||||||
|
image: &Bson,
|
||||||
|
source: &Source,
|
||||||
|
assets: &crate::assets::AssetStore,
|
||||||
|
corners: [glam::Vec2; 4],
|
||||||
|
) -> Option<String> {
|
||||||
|
let bytes = match (image.num("a"), image.get("b")) {
|
||||||
|
(Some(a), _) => (source.assets)(a as usize)?,
|
||||||
|
(None, Some(Bson::Binary(b))) => b.clone(),
|
||||||
|
(None, Some(Bson::Array(b))) => b.iter().filter_map(|v| Some(v.as_i64()? as u8)).collect(),
|
||||||
|
(None, Some(Bson::Str(svg))) => svg.clone().into_bytes(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if image.get("e").and_then(Bson::as_str) == Some(".svg") {
|
||||||
|
let size = glam::Vec2::new(
|
||||||
|
corners[0].distance(corners[1]),
|
||||||
|
corners[0].distance(corners[3]),
|
||||||
|
);
|
||||||
|
let (w, h, pixels) = import::rasterize_svg(std::str::from_utf8(&bytes).ok()?, size)?;
|
||||||
|
return assets.import_rgba(w, h, pixels).ok().map(|i| i.asset);
|
||||||
|
}
|
||||||
|
assets.import_bytes(&bytes).ok().map(|i| i.asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StrokeTool;
|
||||||
|
|
||||||
|
/// BSON encoder for test files.
|
||||||
|
fn encode(value: &Bson) -> Vec<u8> {
|
||||||
|
fn doc(fields: &[(String, Bson)]) -> Vec<u8> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
for (key, value) in fields {
|
||||||
|
let (kind, data): (u8, Vec<u8>) = match value {
|
||||||
|
Bson::Null => (0x0A, Vec::new()),
|
||||||
|
Bson::Bool(b) => (0x08, vec![*b as u8]),
|
||||||
|
Bson::Int(i) => (0x12, i.to_le_bytes().to_vec()),
|
||||||
|
Bson::Double(d) => (0x01, d.to_le_bytes().to_vec()),
|
||||||
|
Bson::Str(s) => {
|
||||||
|
let mut d = ((s.len() + 1) as i32).to_le_bytes().to_vec();
|
||||||
|
d.extend(s.as_bytes());
|
||||||
|
d.push(0);
|
||||||
|
(0x02, d)
|
||||||
|
}
|
||||||
|
Bson::Binary(b) => {
|
||||||
|
let mut d = (b.len() as i32).to_le_bytes().to_vec();
|
||||||
|
d.push(0);
|
||||||
|
d.extend(b);
|
||||||
|
(0x05, d)
|
||||||
|
}
|
||||||
|
Bson::Array(items) => {
|
||||||
|
let fields: Vec<(String, Bson)> = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| (i.to_string(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
(0x04, doc(&fields))
|
||||||
|
}
|
||||||
|
Bson::Doc(fields) => (0x03, doc(fields)),
|
||||||
|
};
|
||||||
|
body.push(kind);
|
||||||
|
body.extend(key.as_bytes());
|
||||||
|
body.push(0);
|
||||||
|
body.extend(data);
|
||||||
|
}
|
||||||
|
let mut out = ((body.len() + 5) as i32).to_le_bytes().to_vec();
|
||||||
|
out.extend(body);
|
||||||
|
out.push(0);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
match value {
|
||||||
|
Bson::Doc(fields) => doc(fields),
|
||||||
|
_ => panic!("only documents"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn d(fields: &[(&str, Bson)]) -> Bson {
|
||||||
|
Bson::Doc(
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn point(x: f32, y: f32, p: f32) -> Bson {
|
||||||
|
Bson::Binary([x, y, p].iter().flat_map(|v| v.to_le_bytes()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png() -> Vec<u8> {
|
||||||
|
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
png
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version 19 document: page 1 with ink, highlighter, a circle, an image and text;
|
||||||
|
/// page 2 with a PDF page as background. Assets: 0 = PNG, 1 = PDF.
|
||||||
|
fn sample() -> Bson {
|
||||||
|
let pen = d(&[
|
||||||
|
(
|
||||||
|
"p",
|
||||||
|
Bson::Array(vec![point(100.0, 100.0, 1.0), point(200.0, 100.0, 0.25)]),
|
||||||
|
),
|
||||||
|
("ty", Bson::Str("fountainPen".into())),
|
||||||
|
("pe", Bson::Bool(true)),
|
||||||
|
("c", Bson::Int(0xff0000ff)),
|
||||||
|
("s", Bson::Double(10.0)),
|
||||||
|
("t", Bson::Double(0.5)),
|
||||||
|
("ox", Bson::Double(10.0)),
|
||||||
|
]);
|
||||||
|
let marker = d(&[
|
||||||
|
(
|
||||||
|
"p",
|
||||||
|
Bson::Array(vec![point(0.0, 300.0, 0.5), point(300.0, 300.0, 0.5)]),
|
||||||
|
),
|
||||||
|
("ty", Bson::Str("Highlighter".into())),
|
||||||
|
("c", Bson::Int(0x80ffff00)),
|
||||||
|
("s", Bson::Double(40.0)),
|
||||||
|
]);
|
||||||
|
let circle = d(&[
|
||||||
|
("shape", Bson::Str("circle".into())),
|
||||||
|
("cx", Bson::Double(500.0)),
|
||||||
|
("cy", Bson::Double(500.0)),
|
||||||
|
("r", Bson::Double(50.0)),
|
||||||
|
("c", Bson::Int(0xff00ff00)),
|
||||||
|
("s", Bson::Double(5.0)),
|
||||||
|
]);
|
||||||
|
let image = d(&[
|
||||||
|
("e", Bson::Str(".png".into())),
|
||||||
|
("a", Bson::Int(0)),
|
||||||
|
("x", Bson::Double(600.0)),
|
||||||
|
("y", Bson::Double(100.0)),
|
||||||
|
("w", Bson::Double(200.0)),
|
||||||
|
("h", Bson::Double(100.0)),
|
||||||
|
]);
|
||||||
|
let text = Bson::Array(vec![
|
||||||
|
d(&[("insert", Bson::Str("Hallo ".into()))]),
|
||||||
|
d(&[("insert", Bson::Str("Welt\n".into()))]),
|
||||||
|
]);
|
||||||
|
let page1 = d(&[
|
||||||
|
("w", Bson::Double(1000.0)),
|
||||||
|
("h", Bson::Double(1400.0)),
|
||||||
|
("s", Bson::Array(vec![pen, marker, circle])),
|
||||||
|
("i", Bson::Array(vec![image])),
|
||||||
|
("q", text),
|
||||||
|
]);
|
||||||
|
let pdf = d(&[
|
||||||
|
("e", Bson::Str(".pdf".into())),
|
||||||
|
("a", Bson::Int(1)),
|
||||||
|
("pdfi", Bson::Int(1)),
|
||||||
|
("nw", Bson::Double(842.0)),
|
||||||
|
("nh", Bson::Double(595.0)),
|
||||||
|
]);
|
||||||
|
let page2 = d(&[
|
||||||
|
("w", Bson::Double(1000.0)),
|
||||||
|
("h", Bson::Double(707.0)),
|
||||||
|
("b", pdf),
|
||||||
|
]);
|
||||||
|
d(&[
|
||||||
|
("v", Bson::Int(19)),
|
||||||
|
("p", Bson::Str("dots".into())),
|
||||||
|
("l", Bson::Int(40)),
|
||||||
|
("z", Bson::Array(vec![page1, page2])),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_pdf() -> Vec<u8> {
|
||||||
|
crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(notebook: &Notebook, report: &ImportReport) {
|
||||||
|
assert_eq!(*report, ImportReport::default());
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
let first = ¬ebook.pages[0];
|
||||||
|
assert_eq!(first.background, PageBackground::DotGrid);
|
||||||
|
// Image, text, then the strokes
|
||||||
|
assert_eq!(first.strokes.len(), 5);
|
||||||
|
assert!(first.strokes[0].is_image());
|
||||||
|
assert_eq!(
|
||||||
|
first.strokes[1].text.as_ref().unwrap().content,
|
||||||
|
"Hallo Welt"
|
||||||
|
);
|
||||||
|
let pen = &first.strokes[2];
|
||||||
|
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||||
|
assert_eq!(pen.color, [0.0, 0.0, 1.0, 1.0]);
|
||||||
|
// 1000 Saber units = 210 mm; the offset moves the stroke by 10
|
||||||
|
assert!((pen.points[0].position.x - 231.0).abs() < 0.1);
|
||||||
|
// perfect_freehand: size 10, thinning 0.5 gives 15 at full pressure and 7.5 at a
|
||||||
|
// quarter, scaled by 2.1
|
||||||
|
let drawn: Vec<f32> = pen
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.map(|s| pen.width * (0.5 + s.pressure))
|
||||||
|
.collect();
|
||||||
|
assert!((drawn[0] - 31.5).abs() < 0.01 && (drawn[1] - 15.75).abs() < 0.01);
|
||||||
|
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
|
||||||
|
assert!((first.strokes[3].color[3] - 128.0 / 255.0).abs() < 1e-3);
|
||||||
|
assert_eq!(first.strokes[4].points.len(), 65);
|
||||||
|
let bg = notebook.pages[1].pdf_background.as_ref().unwrap();
|
||||||
|
assert_eq!(bg.page, 1);
|
||||||
|
assert!(notebook.pages[1].size.x > notebook.pages[1].size.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_bson() {
|
||||||
|
let value = sample();
|
||||||
|
assert_eq!(parse_bson(&encode(&value)).unwrap(), value);
|
||||||
|
assert!(parse_bson(b"\x05\x00\x00").is_err());
|
||||||
|
let mut broken = encode(&value);
|
||||||
|
broken.truncate(broken.len() - 3);
|
||||||
|
assert!(parse_bson(&broken).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_sbn2_with_assets_next_to_it() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = source_dir.path().join("Mathe.sbn2");
|
||||||
|
std::fs::write(&path, encode(&sample())).unwrap();
|
||||||
|
std::fs::write(source_dir.path().join("Mathe.sbn2.0"), png()).unwrap();
|
||||||
|
std::fs::write(source_dir.path().join("Mathe.sbn2.1"), sample_pdf()).unwrap();
|
||||||
|
let (notebook, _, dir, report) =
|
||||||
|
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
check(¬ebook, &report);
|
||||||
|
// Saved and loaded again
|
||||||
|
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
|
||||||
|
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||||
|
check(&reloaded, &report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zip archive with stored (uncompressed) files.
|
||||||
|
fn zip(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut central = Vec::new();
|
||||||
|
for (name, data) in files {
|
||||||
|
let offset = out.len() as u32;
|
||||||
|
let header = |sig: &[u8], central: bool| {
|
||||||
|
let mut h = sig.to_vec();
|
||||||
|
if central {
|
||||||
|
h.extend([20, 0]);
|
||||||
|
}
|
||||||
|
h.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
h.extend((data.len() as u32).to_le_bytes());
|
||||||
|
h.extend((data.len() as u32).to_le_bytes());
|
||||||
|
h.extend((name.len() as u16).to_le_bytes());
|
||||||
|
h.extend([0, 0]);
|
||||||
|
if central {
|
||||||
|
h.extend([0; 10]);
|
||||||
|
h.extend(offset.to_le_bytes());
|
||||||
|
}
|
||||||
|
h.extend(name.as_bytes());
|
||||||
|
h
|
||||||
|
};
|
||||||
|
out.extend(header(&[0x50, 0x4b, 0x03, 0x04], false));
|
||||||
|
out.extend(data);
|
||||||
|
central.extend(header(&[0x50, 0x4b, 0x01, 0x02], true));
|
||||||
|
}
|
||||||
|
let start = out.len() as u32;
|
||||||
|
out.extend(¢ral);
|
||||||
|
out.extend([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
|
||||||
|
out.extend((files.len() as u16).to_le_bytes());
|
||||||
|
out.extend((files.len() as u16).to_le_bytes());
|
||||||
|
out.extend((central.len() as u32).to_le_bytes());
|
||||||
|
out.extend(start.to_le_bytes());
|
||||||
|
out.extend([0, 0]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_import_sba_archive() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = source_dir.path().join("Mathe.sba");
|
||||||
|
let archive = zip(&[
|
||||||
|
("main.sbn2", encode(&sample())),
|
||||||
|
("main.sbn2.0", png()),
|
||||||
|
("main.sbn2.1", sample_pdf()),
|
||||||
|
]);
|
||||||
|
std::fs::write(&path, archive).unwrap();
|
||||||
|
let (notebook, _, _, report) =
|
||||||
|
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
check(¬ebook, &report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_old_json_files() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
// Version 7: strokes outside the pages, points as {x, y, p}
|
||||||
|
let path = source_dir.path().join("Alt.sbn");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{"v": 7, "z": [{"w": 1000, "h": 1400}, {"w": 1000, "h": 1400}],
|
||||||
|
"s": [{"i": 1, "p": [{"x": 0, "y": 0, "p": 0.5}, {"x": 100, "y": 0, "p": 0.5}]}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (notebook, _, _, _) =
|
||||||
|
create_notebook_from_saber(temp_dir.path(), "Alt", &path).unwrap();
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 0);
|
||||||
|
assert_eq!(notebook.pages[1].strokes.len(), 1);
|
||||||
|
|
||||||
|
// The very first format: just a list of strokes
|
||||||
|
let path = source_dir.path().join("Uralt.sbn");
|
||||||
|
std::fs::write(&path, "[]").unwrap();
|
||||||
|
assert!(create_notebook_from_saber(temp_dir.path(), "Uralt", &path).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-6
@@ -229,6 +229,9 @@ pub struct UiSettings {
|
|||||||
pub language: Option<Language>,
|
pub language: Option<Language>,
|
||||||
/// Eraser radius in mm (Issue #38)
|
/// Eraser radius in mm (Issue #38)
|
||||||
pub eraser_radius_mm: f32,
|
pub eraser_radius_mm: f32,
|
||||||
|
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
||||||
|
/// (Issue #49)
|
||||||
|
pub storage_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiSettings {
|
impl Default for UiSettings {
|
||||||
@@ -249,18 +252,15 @@ impl Default for UiSettings {
|
|||||||
stylus_buttons: Vec::new(),
|
stylus_buttons: Vec::new(),
|
||||||
language: None,
|
language: None,
|
||||||
eraser_radius_mm: 3.0,
|
eraser_radius_mm: 3.0,
|
||||||
|
storage_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiSettings {
|
impl UiSettings {
|
||||||
/// Standardpfad gemäß XDG-Spezifikation.
|
/// Standardpfad gemäß XDG-Spezifikation (see [`crate::paths`] for other systems).
|
||||||
pub fn default_path() -> Option<PathBuf> {
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
let base = match std::env::var("XDG_CONFIG_HOME") {
|
crate::paths::config_dir().map(|d| d.join("ui.json"))
|
||||||
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
|
||||||
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
|
||||||
};
|
|
||||||
Some(base.join("ink-notes").join("ui.json"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||||
@@ -349,6 +349,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")),
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|||||||
+159
-19
@@ -939,23 +939,64 @@ pub fn ensure_page_loaded(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notebook folder inside a folder the user picked as storage location (Issue #49), e.g. the
|
||||||
|
/// Nextcloud folder: `<chosen>/ink-notes`, unless the folder already is called `ink-notes`.
|
||||||
|
pub fn storage_dir_in(chosen: &Path) -> PathBuf {
|
||||||
|
if chosen.file_name().is_some_and(|n| n == "ink-notes") {
|
||||||
|
chosen.to_path_buf()
|
||||||
|
} else {
|
||||||
|
chosen.join("ink-notes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares `to` as new storage location. If it holds no notebooks yet, the notebooks and
|
||||||
|
/// `library.json` of `from` are copied there (lock files and open markers excluded); the
|
||||||
|
/// originals stay where they are. Returns the number of copied notebooks.
|
||||||
|
pub fn copy_storage(from: &Path, to: &Path) -> io::Result<usize> {
|
||||||
|
create_secure_dir_all(to)?;
|
||||||
|
if !list_notebooks(to).is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut copied = 0;
|
||||||
|
for summary in list_notebooks(from) {
|
||||||
|
let target = to.join(&summary.id);
|
||||||
|
if target.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
copy_dir(&from.join(&summary.id), &target)?;
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
let library = from.join("library.json");
|
||||||
|
if library.is_file() && !to.join("library.json").exists() {
|
||||||
|
fs::copy(&library, to.join("library.json"))?;
|
||||||
|
}
|
||||||
|
Ok(copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies a notebook folder recursively, without lock file and open marker.
|
||||||
|
fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
|
||||||
|
create_secure_dir_all(to)?;
|
||||||
|
for entry in fs::read_dir(from)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name == ".lock" || name == OPEN_MARKER_FILE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind = entry.file_type()?;
|
||||||
|
if kind.is_dir() {
|
||||||
|
copy_dir(&entry.path(), &to.join(&name))?;
|
||||||
|
} else if kind.is_file() {
|
||||||
|
fs::copy(entry.path(), to.join(&name))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
||||||
pub fn default_storage_dir() -> PathBuf {
|
pub fn default_storage_dir() -> PathBuf {
|
||||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
crate::paths::data_dir()
|
||||||
if !xdg.is_empty() {
|
.map(|d| d.join("notebooks"))
|
||||||
return PathBuf::from(xdg).join("ink-notes").join("notebooks");
|
.unwrap_or_else(|| PathBuf::from("./data/notebooks"))
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(home) = std::env::var("HOME") {
|
|
||||||
if !home.is_empty() {
|
|
||||||
return PathBuf::from(home)
|
|
||||||
.join(".local")
|
|
||||||
.join("share")
|
|
||||||
.join("ink-notes")
|
|
||||||
.join("notebooks");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PathBuf::from("./data/notebooks")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status eines Notizbuchs in der Dokumentenliste.
|
/// Status eines Notizbuchs in der Dokumentenliste.
|
||||||
@@ -984,6 +1025,49 @@ pub struct NotebookSummary {
|
|||||||
pub struct NotebookLock {
|
pub struct NotebookLock {
|
||||||
_file: File,
|
_file: File,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
/// Another device that has the notebook open according to its marker (Issue #49)
|
||||||
|
pub open_elsewhere: Option<OpenMarker>,
|
||||||
|
marker: Option<(PathBuf, OpenMarker)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which device has a notebook open, and since when (Unix seconds). The `.lock` file only
|
||||||
|
/// locks on this computer; this marker (`.opened_by.json`) travels with sync clients such as
|
||||||
|
/// Nextcloud, so another device can warn before both edit at the same time (Issue #49).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct OpenMarker {
|
||||||
|
pub device: String,
|
||||||
|
pub since: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File name of the [`OpenMarker`] in the notebook folder.
|
||||||
|
pub const OPEN_MARKER_FILE: &str = ".opened_by.json";
|
||||||
|
|
||||||
|
/// Name of this device (host name).
|
||||||
|
pub fn device_name() -> String {
|
||||||
|
let from_file = |path: &str| fs::read_to_string(path).ok();
|
||||||
|
std::env::var("INK_NOTES_DEVICE")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| from_file("/proc/sys/kernel/hostname"))
|
||||||
|
.or_else(|| from_file("/etc/hostname"))
|
||||||
|
.or_else(|| std::env::var("COMPUTERNAME").ok())
|
||||||
|
.or_else(|| std::env::var("HOSTNAME").ok())
|
||||||
|
.map(|name| name.trim().to_string())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| "?".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for NotebookLock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Remove our marker, unless another device has replaced it meanwhile
|
||||||
|
if let Some((path, ours)) = &self.marker {
|
||||||
|
let current = fs::read(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok());
|
||||||
|
if current.as_ref() == Some(ours) {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotebookLock {
|
impl NotebookLock {
|
||||||
@@ -1007,13 +1091,38 @@ impl NotebookLock {
|
|||||||
|
|
||||||
use fs2::FileExt;
|
use fs2::FileExt;
|
||||||
match file.try_lock_exclusive() {
|
match file.try_lock_exclusive() {
|
||||||
Ok(()) => Ok(Self {
|
Ok(()) => {
|
||||||
|
let device = device_name();
|
||||||
|
let marker_path = notebook_dir.join(OPEN_MARKER_FILE);
|
||||||
|
let open_elsewhere = fs::read(&marker_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok())
|
||||||
|
.filter(|m| m.device != device);
|
||||||
|
let ours = OpenMarker {
|
||||||
|
device,
|
||||||
|
since: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs(),
|
||||||
|
};
|
||||||
|
let marker = serde_json::to_vec(&ours)
|
||||||
|
.ok()
|
||||||
|
.filter(|json| atomic_write_file(&marker_path, json).is_ok())
|
||||||
|
.map(|_| (marker_path, ours));
|
||||||
|
Ok(Self {
|
||||||
_file: file,
|
_file: file,
|
||||||
path: lock_path,
|
path: lock_path,
|
||||||
}),
|
open_elsewhere,
|
||||||
|
marker,
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let kind = e.kind();
|
let kind = e.kind();
|
||||||
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
|
// Windows reports a held lock as ERROR_LOCK_VIOLATION
|
||||||
|
if kind == io::ErrorKind::WouldBlock
|
||||||
|
|| e.raw_os_error() == Some(11)
|
||||||
|
|| e.raw_os_error() == fs2::lock_contended_error().raw_os_error()
|
||||||
|
{
|
||||||
Err(StorageError::AlreadyLocked(t!(
|
Err(StorageError::AlreadyLocked(t!(
|
||||||
"Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet",
|
"Notizbuch '{path}' wird bereits von einer anderen Instanz verwendet",
|
||||||
path = notebook_dir.display()
|
path = notebook_dir.display()
|
||||||
@@ -2159,6 +2268,37 @@ mod tests {
|
|||||||
assert_eq!(damaged_summary.unwrap().status, NotebookStatus::Damaged);
|
assert_eq!(damaged_summary.unwrap().status, NotebookStatus::Damaged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_copy_storage_to_new_location() {
|
||||||
|
let old = tempfile::tempdir().unwrap();
|
||||||
|
let cloud = tempfile::tempdir().unwrap();
|
||||||
|
let (notebook, mut manifest, dir) = create_new_notebook(old.path(), "Mathe").unwrap();
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
|
let _lock = NotebookLock::acquire(&dir).unwrap();
|
||||||
|
fs::create_dir_all(dir.join("assets")).unwrap();
|
||||||
|
fs::write(dir.join("assets").join("bild.png"), b"png").unwrap();
|
||||||
|
fs::write(old.path().join("library.json"), b"{}").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(storage_dir_in(cloud.path()), cloud.path().join("ink-notes"));
|
||||||
|
let target = storage_dir_in(cloud.path());
|
||||||
|
assert_eq!(storage_dir_in(&target), target);
|
||||||
|
|
||||||
|
assert_eq!(copy_storage(old.path(), &target).unwrap(), 1);
|
||||||
|
let copied = target.join(&manifest.id);
|
||||||
|
assert!(load_notebook(&copied).is_ok());
|
||||||
|
assert!(copied.join("assets").join("bild.png").exists());
|
||||||
|
assert!(target.join("library.json").exists());
|
||||||
|
assert!(!copied.join(".lock").exists());
|
||||||
|
assert!(!copied.join(OPEN_MARKER_FILE).exists());
|
||||||
|
// The originals stay
|
||||||
|
assert!(load_notebook(&dir).is_ok());
|
||||||
|
|
||||||
|
// Notebooks already there (e.g. from another device): nothing is copied
|
||||||
|
create_new_notebook(old.path(), "Physik").unwrap();
|
||||||
|
assert_eq!(copy_storage(old.path(), &target).unwrap(), 0);
|
||||||
|
assert_eq!(list_notebooks(&target).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_notebook_instance_lock_prevents_concurrent_open() {
|
fn test_notebook_instance_lock_prevents_concurrent_open() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
@@ -2385,7 +2525,7 @@ mod tests {
|
|||||||
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
|
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
|
||||||
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||||
|
|
||||||
// Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern
|
// Wie Taste N in app.rs: neue Seite anhängen, Strich darauf, speichern
|
||||||
notebook.next_page();
|
notebook.next_page();
|
||||||
let idx = notebook.current_page_index;
|
let idx = notebook.current_page_index;
|
||||||
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
|
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
|
||||||
|
|||||||
@@ -110,8 +110,10 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [
|
|||||||
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] =
|
||||||
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
[(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")];
|
||||||
|
|
||||||
/// Eraser radii in mm, small to large; the toolbar button cycles through them (Issue #38).
|
/// Eraser size presets as radius in mm, small to large (Issue #38).
|
||||||
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
|
pub const ERASER_RADII_MM: [f32; 4] = [1.5, 3.0, 6.0, 12.0];
|
||||||
|
/// Range of the eraser size slider (diameter in mm).
|
||||||
|
pub const ERASER_DIAMETER_RANGE_MM: (f32, f32) = (2.0, 30.0);
|
||||||
|
|
||||||
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom
|
||||||
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt.
|
||||||
@@ -336,7 +338,9 @@ pub enum UiAction {
|
|||||||
/// Bilddatei auf der aktuellen Seite einfügen
|
/// Bilddatei auf der aktuellen Seite einfügen
|
||||||
InsertImageFile(std::path::PathBuf),
|
InsertImageFile(std::path::PathBuf),
|
||||||
/// PDF als neues Notizbuch importieren
|
/// PDF als neues Notizbuch importieren
|
||||||
ImportPdf(std::path::PathBuf),
|
ImportFile(std::path::PathBuf),
|
||||||
|
/// Store the notebooks in this folder, `None` for the default location (Issue #49)
|
||||||
|
SetStorageDir(Option<std::path::PathBuf>),
|
||||||
/// Aktuelle Seite als Bild oder SVG exportieren
|
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||||
ExportPage(PageExportKind),
|
ExportPage(PageExportKind),
|
||||||
/// Titel einer Seite setzen (leer = entfernen)
|
/// Titel einer Seite setzen (leer = entfernen)
|
||||||
@@ -376,6 +380,8 @@ pub struct UiState {
|
|||||||
pub toolbar_rect: Option<egui::Rect>,
|
pub toolbar_rect: Option<egui::Rect>,
|
||||||
/// Öffnungszustand des Einstellungs-Popovers für den Stift
|
/// Öffnungszustand des Einstellungs-Popovers für den Stift
|
||||||
pub is_pen_menu_open: bool,
|
pub is_pen_menu_open: bool,
|
||||||
|
/// Eraser size menu next to the size button (Issue #38)
|
||||||
|
pub is_eraser_menu_open: bool,
|
||||||
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
|
/// Öffnungszustand des Einstellungs-Popovers für den Textmarker
|
||||||
pub is_highlighter_menu_open: bool,
|
pub is_highlighter_menu_open: bool,
|
||||||
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
|
/// Bildschirm-Rechteck des geöffneten Einstellungs-Popovers
|
||||||
@@ -499,6 +505,9 @@ pub struct UiState {
|
|||||||
pub queued_actions: Vec<UiAction>,
|
pub queued_actions: Vec<UiAction>,
|
||||||
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||||||
pub last_picker_dir: Option<std::path::PathBuf>,
|
pub last_picker_dir: Option<std::path::PathBuf>,
|
||||||
|
/// Folder holding the notebooks and whether the user picked it (Issue #49)
|
||||||
|
pub storage_dir: std::path::PathBuf,
|
||||||
|
pub storage_dir_custom: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -514,6 +523,7 @@ impl Default for UiState {
|
|||||||
current_zoom: 1.0,
|
current_zoom: 1.0,
|
||||||
toolbar_rect: None,
|
toolbar_rect: None,
|
||||||
is_pen_menu_open: false,
|
is_pen_menu_open: false,
|
||||||
|
is_eraser_menu_open: false,
|
||||||
is_highlighter_menu_open: false,
|
is_highlighter_menu_open: false,
|
||||||
popover_rect: None,
|
popover_rect: None,
|
||||||
is_document_menu_open: false,
|
is_document_menu_open: false,
|
||||||
@@ -583,6 +593,8 @@ impl Default for UiState {
|
|||||||
page_title_for: None,
|
page_title_for: None,
|
||||||
page_picker_list: false,
|
page_picker_list: false,
|
||||||
last_picker_dir: None,
|
last_picker_dir: None,
|
||||||
|
storage_dir: std::path::PathBuf::new(),
|
||||||
|
storage_dir_custom: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,21 +607,14 @@ impl UiState {
|
|||||||
/// Abstand der Werkzeugleiste zum Fensterrand.
|
/// Abstand der Werkzeugleiste zum Fensterrand.
|
||||||
pub const SIDEBAR_MARGIN: f32 = 10.0;
|
pub const SIDEBAR_MARGIN: f32 = 10.0;
|
||||||
|
|
||||||
/// Index of the eraser size in [`ERASER_RADII_MM`] closest to the current radius.
|
/// Sets the eraser radius within the slider range (e.g. from the settings file).
|
||||||
pub fn eraser_size_index(&self) -> usize {
|
|
||||||
(0..ERASER_RADII_MM.len())
|
|
||||||
.min_by(|&a, &b| {
|
|
||||||
let da = (ERASER_RADII_MM[a] - self.eraser_radius_mm).abs();
|
|
||||||
let db = (ERASER_RADII_MM[b] - self.eraser_radius_mm).abs();
|
|
||||||
da.total_cmp(&db)
|
|
||||||
})
|
|
||||||
.unwrap_or(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets the eraser radius to the closest available size (e.g. from the settings file).
|
|
||||||
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
|
pub fn set_eraser_radius_mm(&mut self, mm: f32) {
|
||||||
self.eraser_radius_mm = mm;
|
let (min, max) = ERASER_DIAMETER_RANGE_MM;
|
||||||
self.eraser_radius_mm = ERASER_RADII_MM[self.eraser_size_index()];
|
self.eraser_radius_mm = if mm.is_finite() {
|
||||||
|
mm.clamp(min / 2.0, max / 2.0)
|
||||||
|
} else {
|
||||||
|
ERASER_RADII_MM[1]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eraser radius in page units.
|
/// Eraser radius in page units.
|
||||||
@@ -624,6 +629,7 @@ impl UiState {
|
|||||||
self.context_menu = None;
|
self.context_menu = None;
|
||||||
self.is_text_color_menu_open = false;
|
self.is_text_color_menu_open = false;
|
||||||
self.is_pen_menu_open = false;
|
self.is_pen_menu_open = false;
|
||||||
|
self.is_eraser_menu_open = false;
|
||||||
self.is_highlighter_menu_open = false;
|
self.is_highlighter_menu_open = false;
|
||||||
self.is_more_menu_open = false;
|
self.is_more_menu_open = false;
|
||||||
self.show_page_jump_popup = false;
|
self.show_page_jump_popup = false;
|
||||||
@@ -670,14 +676,25 @@ impl UiState {
|
|||||||
self.open_file_picker(PickerKind::Image);
|
self.open_file_picker(PickerKind::Image);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
|
/// Opens the file picker "Import" (PDF, Xournal++, Rnote, Saber) in the last used folder.
|
||||||
pub fn open_pdf_picker(&mut self) {
|
pub fn open_import_picker(&mut self) {
|
||||||
self.open_file_picker(PickerKind::Pdf);
|
self.open_file_picker(PickerKind::Import);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the folder picker for the storage location in the home folder.
|
||||||
|
pub fn open_storage_picker(&mut self) {
|
||||||
|
self.close_overlays();
|
||||||
|
self.file_picker = Some(FilePicker::open(PickerKind::Folder, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +981,61 @@ fn stroke_preview_button(
|
|||||||
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
|
response.on_hover_text(t!("Stärke auf {width} setzen", width = label_text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eraser size button of the eraser menu: a circle in proportion to the size and its diameter.
|
||||||
|
fn eraser_size_button(ui: &mut egui::Ui, is_active: bool, radius_mm: f32) -> egui::Response {
|
||||||
|
let (rect, response) = ui.allocate_exact_size(egui::vec2(66.0, 40.0), egui::Sense::click());
|
||||||
|
let label = format!("{} mm", format_mm(radius_mm * 2.0));
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let painter = ui.painter();
|
||||||
|
let (bg, border) = if is_active {
|
||||||
|
(
|
||||||
|
egui::Color32::from_rgba_premultiplied(50, 85, 150, 220),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(120, 175, 255, 200),
|
||||||
|
)
|
||||||
|
} else if response.hovered() {
|
||||||
|
(
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 25),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8),
|
||||||
|
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 14),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
painter.rect_filled(rect, egui::Rounding::same(6.0), bg);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::Rounding::same(6.0),
|
||||||
|
egui::Stroke::new(1.0_f32, border),
|
||||||
|
);
|
||||||
|
let text_color = if is_active {
|
||||||
|
egui::Color32::WHITE
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_gray(190)
|
||||||
|
};
|
||||||
|
painter.circle_stroke(
|
||||||
|
egui::pos2(rect.center().x, rect.center().y - 5.0),
|
||||||
|
(radius_mm * 0.9).clamp(1.5, 11.0),
|
||||||
|
egui::Stroke::new(1.5_f32, text_color),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.center().x, rect.max.y - 7.5),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
&label,
|
||||||
|
egui::FontId::proportional(10.0),
|
||||||
|
text_color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.on_hover_text(t!("Radiergröße {size} setzen", size = label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Millimeters without trailing zeros: 3, 1.5, 2.25.
|
||||||
|
fn format_mm(mm: f32) -> String {
|
||||||
|
let text = format!("{:.2}", mm);
|
||||||
|
text.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
|
/// Inhalt eines Symbol-Knopfs der Werkzeugleiste.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum SidebarIcon<'a> {
|
enum SidebarIcon<'a> {
|
||||||
@@ -1379,8 +1451,36 @@ fn color_circle_swatch(
|
|||||||
|
|
||||||
/// Spalten der Seitenauswahl.
|
/// Spalten der Seitenauswahl.
|
||||||
const PAGE_PICKER_COLUMNS: usize = 4;
|
const PAGE_PICKER_COLUMNS: usize = 4;
|
||||||
/// Größe einer Kachel der Seitenauswahl: A4-Vorschau plus Zeile für die Seitenzahl.
|
/// Breite einer Kachel der Seitenauswahl; darunter kommt eine Zeile für die Seitenzahl.
|
||||||
const PAGE_TILE: egui::Vec2 = egui::vec2(66.0, 66.0 * PAGE_HEIGHT / PAGE_WIDTH + 18.0);
|
const PAGE_TILE_WIDTH: f32 = 66.0;
|
||||||
|
const PAGE_TILE_LABEL: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Size of a page picker tile: as high as the tallest page of the notebook needs, so that
|
||||||
|
/// slides (landscape PDFs) get flat tiles without an empty strip (Issue #40).
|
||||||
|
fn page_tile_size(notebook: &Notebook) -> egui::Vec2 {
|
||||||
|
let aspect = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.size.y / p.size.x.max(1.0))
|
||||||
|
.fold(0.0_f32, f32::max);
|
||||||
|
let aspect = if aspect > 0.0 {
|
||||||
|
aspect.clamp(0.3, 2.0)
|
||||||
|
} else {
|
||||||
|
PAGE_HEIGHT / PAGE_WIDTH
|
||||||
|
};
|
||||||
|
egui::vec2(PAGE_TILE_WIDTH, PAGE_TILE_WIDTH * aspect + PAGE_TILE_LABEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rectangle of a page of `page_size` in a thumbnail area: as large as possible, centered
|
||||||
|
/// horizontally and at the top.
|
||||||
|
fn thumbnail_sheet(page_size: glam::Vec2, rect: egui::Rect) -> egui::Rect {
|
||||||
|
let scale = (rect.width() / page_size.x).min(rect.height() / page_size.y);
|
||||||
|
let size = egui::vec2(page_size.x, page_size.y) * scale;
|
||||||
|
egui::Rect::from_min_size(
|
||||||
|
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
}
|
||||||
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
/// Höchstzahl gezeichneter Punkte pro Vorschau (dünnt volle Seiten aus).
|
||||||
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
const THUMBNAIL_POINT_BUDGET: usize = 1500;
|
||||||
|
|
||||||
@@ -1396,12 +1496,8 @@ pub fn page_thumbnail_shapes(
|
|||||||
adapt_ink: bool,
|
adapt_ink: bool,
|
||||||
pdf_texture: Option<egui::TextureId>,
|
pdf_texture: Option<egui::TextureId>,
|
||||||
) -> Vec<egui::Shape> {
|
) -> Vec<egui::Shape> {
|
||||||
let scale = (rect.width() / page.size.x).min(rect.height() / page.size.y);
|
let sheet = thumbnail_sheet(page.size, rect);
|
||||||
let size = egui::vec2(page.size.x, page.size.y) * scale;
|
let scale = sheet.width() / page.size.x;
|
||||||
let sheet = egui::Rect::from_min_size(
|
|
||||||
rect.min + egui::vec2((rect.width() - size.x) / 2.0, 0.0),
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
let mut shapes = vec![egui::Shape::rect_filled(
|
let mut shapes = vec![egui::Shape::rect_filled(
|
||||||
sheet,
|
sheet,
|
||||||
egui::Rounding::same(3.0),
|
egui::Rounding::same(3.0),
|
||||||
@@ -1632,14 +1728,13 @@ fn page_thumbnail(
|
|||||||
page: &Page,
|
page: &Page,
|
||||||
number: usize,
|
number: usize,
|
||||||
is_current: bool,
|
is_current: bool,
|
||||||
|
tile: egui::Vec2,
|
||||||
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
|
wanted_pdfs: &mut Vec<crate::document::PdfBackground>,
|
||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
let (rect, response) = ui.allocate_exact_size(PAGE_TILE, egui::Sense::click());
|
let (rect, response) = ui.allocate_exact_size(tile, egui::Sense::click());
|
||||||
if ui.is_rect_visible(rect) {
|
if ui.is_rect_visible(rect) {
|
||||||
let thumb = egui::Rect::from_min_size(
|
let thumb =
|
||||||
rect.min,
|
egui::Rect::from_min_size(rect.min, egui::vec2(tile.x, tile.y - PAGE_TILE_LABEL));
|
||||||
egui::vec2(PAGE_TILE.x, PAGE_TILE.x * PAGE_HEIGHT / PAGE_WIDTH),
|
|
||||||
);
|
|
||||||
let pdf_texture = match &page.pdf_background {
|
let pdf_texture = match &page.pdf_background {
|
||||||
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
|
Some(pdf) => match state.pdf_thumbnails.get(&pdf.key()) {
|
||||||
Some(texture) => texture.as_ref().map(|t| t.0.id()),
|
Some(texture) => texture.as_ref().map(|t| t.0.id()),
|
||||||
@@ -1658,9 +1753,11 @@ fn page_thumbnail(
|
|||||||
state.adapt_ink_colors,
|
state.adapt_ink_colors,
|
||||||
pdf_texture,
|
pdf_texture,
|
||||||
));
|
));
|
||||||
|
// Frame and marks follow the page itself, not the whole tile
|
||||||
|
let sheet = thumbnail_sheet(page.size, thumb);
|
||||||
if !page.is_loaded {
|
if !page.is_loaded {
|
||||||
painter.text(
|
painter.text(
|
||||||
thumb.center(),
|
sheet.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
"…",
|
"…",
|
||||||
egui::FontId::proportional(16.0),
|
egui::FontId::proportional(16.0),
|
||||||
@@ -1669,7 +1766,7 @@ fn page_thumbnail(
|
|||||||
}
|
}
|
||||||
// Lesezeichen-Ecke für benannte Seiten
|
// Lesezeichen-Ecke für benannte Seiten
|
||||||
if page.title.is_some() {
|
if page.title.is_some() {
|
||||||
let corner = thumb.right_top();
|
let corner = sheet.right_top();
|
||||||
painter.add(egui::Shape::convex_polygon(
|
painter.add(egui::Shape::convex_polygon(
|
||||||
vec![
|
vec![
|
||||||
corner + egui::vec2(-14.0, 0.0),
|
corner + egui::vec2(-14.0, 0.0),
|
||||||
@@ -1687,7 +1784,7 @@ fn page_thumbnail(
|
|||||||
} else {
|
} else {
|
||||||
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
|
egui::Stroke::new(1.0_f32, egui::Color32::from_white_alpha(60))
|
||||||
};
|
};
|
||||||
painter.rect_stroke(thumb, egui::Rounding::same(3.0), border);
|
painter.rect_stroke(sheet, egui::Rounding::same(3.0), border);
|
||||||
painter.text(
|
painter.text(
|
||||||
egui::pos2(rect.center().x, rect.max.y - 8.0),
|
egui::pos2(rect.center().x, rect.max.y - 8.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
@@ -2748,14 +2845,27 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
|||||||
t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
|
t!("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)"),
|
||||||
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
t!("Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen."),
|
||||||
),
|
),
|
||||||
PickerKind::Pdf => (
|
PickerKind::Import => (
|
||||||
t!("PDF importieren"),
|
t!("Importieren"),
|
||||||
t!("📄 PDF importieren"),
|
t!("📄 Importieren"),
|
||||||
"📄",
|
"📄",
|
||||||
t!("Keine Ordner oder PDF-Dateien"),
|
t!("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)"),
|
||||||
t!(
|
t!(
|
||||||
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
|
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
|
||||||
Beim Export liegen deine Notizen über den Originalseiten."
|
beim Export liegen deine Notizen über den Originalseiten. \
|
||||||
|
Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, \
|
||||||
|
Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PickerKind::Folder => (
|
||||||
|
t!("Speicherort wählen"),
|
||||||
|
t!("📁 Speicherort wählen"),
|
||||||
|
"📁",
|
||||||
|
t!("Keine Unterordner"),
|
||||||
|
t!(
|
||||||
|
"Die Notizbücher liegen dann im Unterordner „ink-notes“ des gewählten Ordners, \
|
||||||
|
z. B. im Nextcloud- oder Google-Drive-Ordner. Ist er noch leer, werden die \
|
||||||
|
vorhandenen Notizbücher dorthin kopiert; die alten bleiben erhalten."
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -2796,6 +2906,16 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
|||||||
if let Some(err) = &picker.error {
|
if let Some(err) = &picker.error {
|
||||||
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
|
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
|
||||||
}
|
}
|
||||||
|
if picker_kind == PickerKind::Folder
|
||||||
|
&& ui
|
||||||
|
.add_sized(
|
||||||
|
[ui.available_width(), 30.0],
|
||||||
|
egui::Button::new(t!("✔ Diesen Ordner wählen")),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
chosen = Some(picker.dir.clone());
|
||||||
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
@@ -2842,10 +2962,13 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(path) = chosen {
|
if let Some(path) = chosen {
|
||||||
|
if picker_kind != PickerKind::Folder {
|
||||||
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||||
|
}
|
||||||
actions.push(match picker_kind {
|
actions.push(match picker_kind {
|
||||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||||
PickerKind::Pdf => UiAction::ImportPdf(path),
|
PickerKind::Import => UiAction::ImportFile(path),
|
||||||
|
PickerKind::Folder => UiAction::SetStorageDir(Some(path)),
|
||||||
});
|
});
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
@@ -3030,6 +3153,7 @@ pub fn render_ui(
|
|||||||
context_menu(ctx, state, &mut actions);
|
context_menu(ctx, state, &mut actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
|
let mut eraser_btn_rect = None;
|
||||||
let mut high_btn_rect = None;
|
let mut high_btn_rect = None;
|
||||||
let mut text_color_btn_rect = None;
|
let mut text_color_btn_rect = None;
|
||||||
let mut more_btn_rect = None;
|
let mut more_btn_rect = None;
|
||||||
@@ -3430,31 +3554,27 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
used += swatch + spacing;
|
used += swatch + spacing;
|
||||||
}
|
}
|
||||||
// Eraser size: tapping cycles small → large (Issue #38)
|
// Eraser size: opens a menu like the pens (Issue #38)
|
||||||
let index = state.eraser_size_index();
|
let diameter = format_mm(state.eraser_radius_mm * 2.0);
|
||||||
let diameter = format!("{}", ERASER_RADII_MM[index] * 2.0);
|
|
||||||
let tooltip = t!(
|
let tooltip = t!(
|
||||||
"Radiergröße {size} mm – tippen für die nächste Größe",
|
"Radiergröße {size} mm – tippen zum Ändern",
|
||||||
size = diameter
|
size = diameter
|
||||||
);
|
);
|
||||||
let dot = [2.5_f32, 4.0, 6.0, 8.5][index].min(swatch * 0.45);
|
let dot = (state.eraser_radius_mm * 0.7).clamp(2.0, swatch * 0.45);
|
||||||
if sidebar_button(
|
let response = sidebar_button(
|
||||||
ui,
|
ui,
|
||||||
swatch,
|
swatch,
|
||||||
SidebarIcon::Dot(dot),
|
SidebarIcon::Dot(dot),
|
||||||
false,
|
state.is_eraser_menu_open,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
&tooltip,
|
&tooltip,
|
||||||
)
|
);
|
||||||
.clicked()
|
eraser_btn_rect = Some(response.rect);
|
||||||
{
|
if response.clicked() {
|
||||||
let next = (index + 1) % ERASER_RADII_MM.len();
|
let open = !state.is_eraser_menu_open;
|
||||||
state.eraser_radius_mm = ERASER_RADII_MM[next];
|
state.close_overlays();
|
||||||
state.set_export_message(t!(
|
state.is_eraser_menu_open = open;
|
||||||
"Radiergröße {size} mm",
|
|
||||||
size = format!("{}", ERASER_RADII_MM[next] * 2.0)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
used += swatch + spacing;
|
used += swatch + spacing;
|
||||||
}
|
}
|
||||||
@@ -3776,6 +3896,84 @@ pub fn render_ui(
|
|||||||
frame.response.rect
|
frame.response.rect
|
||||||
});
|
});
|
||||||
|
|
||||||
|
popover_rect = Some(pop_response.inner);
|
||||||
|
}
|
||||||
|
} else if state.is_eraser_menu_open && state.active_tool == ActiveTool::Eraser {
|
||||||
|
if let Some(btn_rect) = eraser_btn_rect {
|
||||||
|
let (popover_pos, pivot) =
|
||||||
|
popover_placement(state.sidebar_side, area_response.inner, btn_rect);
|
||||||
|
let pop_response = egui::Area::new(egui::Id::new("eraser_settings_popover"))
|
||||||
|
.fixed_pos(popover_pos)
|
||||||
|
.pivot(pivot)
|
||||||
|
.constrain(true)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let frame = popover_frame().show(ui, |ui| {
|
||||||
|
ui.set_width(320.0);
|
||||||
|
touch_friendly_spacing(ui);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("Radierer"))
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::WHITE)
|
||||||
|
.size(12.5),
|
||||||
|
);
|
||||||
|
ui.with_layout(
|
||||||
|
egui::Layout::right_to_left(egui::Align::Center),
|
||||||
|
|ui| {
|
||||||
|
if close_button(ui).clicked() {
|
||||||
|
state.is_eraser_menu_open = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("GRÖSSE"))
|
||||||
|
.size(10.5)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
ui.add_space(3.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0);
|
||||||
|
for radius in ERASER_RADII_MM {
|
||||||
|
let is_sel = (state.eraser_radius_mm - radius).abs() < 0.05;
|
||||||
|
if eraser_size_button(ui, is_sel, radius).clicked() {
|
||||||
|
state.eraser_radius_mm = radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(t!("STUFENLOSE GRÖSSE"))
|
||||||
|
.size(10.5)
|
||||||
|
.strong()
|
||||||
|
.color(egui::Color32::from_gray(160)),
|
||||||
|
);
|
||||||
|
ui.add_space(2.0);
|
||||||
|
let mut diameter = state.eraser_radius_mm * 2.0;
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Slider::new(
|
||||||
|
&mut diameter,
|
||||||
|
ERASER_DIAMETER_RANGE_MM.0..=ERASER_DIAMETER_RANGE_MM.1,
|
||||||
|
)
|
||||||
|
.suffix(" mm")
|
||||||
|
.step_by(0.5),
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
state.set_eraser_radius_mm(diameter / 2.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frame.response.rect
|
||||||
|
});
|
||||||
|
|
||||||
popover_rect = Some(pop_response.inner);
|
popover_rect = Some(pop_response.inner);
|
||||||
}
|
}
|
||||||
} else if state.is_highlighter_menu_open {
|
} else if state.is_highlighter_menu_open {
|
||||||
@@ -4041,11 +4239,11 @@ pub fn render_ui(
|
|||||||
state.open_image_picker();
|
state.open_image_picker();
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 PDF importieren …")))
|
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote, Saber) …")))
|
||||||
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben"))
|
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
state.open_pdf_picker();
|
state.open_import_picker();
|
||||||
}
|
}
|
||||||
|
|
||||||
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
section(ui, t!("HINTERGRUND DIESER SEITE"));
|
||||||
@@ -4241,6 +4439,36 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
section(ui, t!("SPEICHERORT"));
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
egui::RichText::new(state.storage_dir.display().to_string())
|
||||||
|
.size(11.5)
|
||||||
|
.color(egui::Color32::from_gray(190)),
|
||||||
|
)
|
||||||
|
.truncate(true),
|
||||||
|
)
|
||||||
|
.on_hover_text(state.storage_dir.display().to_string());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.add_sized([115.0, 28.0], egui::Button::new(t!("📁 Ändern …")))
|
||||||
|
.on_hover_text(t!("Z. B. einen Nextcloud- oder Google-Drive-Ordner wählen, um die Notizbücher mit anderen Geräten abzugleichen"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.open_storage_picker();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
state.storage_dir_custom,
|
||||||
|
egui::Button::new(t!("Standard")).min_size(egui::vec2(115.0, 28.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text(t!("Zurück zum Standard-Speicherort"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::SetStorageDir(None));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
section(ui, t!("STATUS"));
|
section(ui, t!("STATUS"));
|
||||||
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -4295,7 +4523,9 @@ pub fn render_ui(
|
|||||||
popover_frame()
|
popover_frame()
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
|
// Breite = Kachelraster; sonst nimmt die Kopfzeile die ganze Fensterbreite ein
|
||||||
ui.set_width(PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE.x + 8.0) - 8.0);
|
ui.set_width(
|
||||||
|
PAGE_PICKER_COLUMNS as f32 * (PAGE_TILE_WIDTH + 8.0) - 8.0,
|
||||||
|
);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
let title = t!("Seiten ({count})", count = notebook.pages.len());
|
let title = t!("Seiten ({count})", count = notebook.pages.len());
|
||||||
if dialog_header(ui, &title, true) {
|
if dialog_header(ui, &title, true) {
|
||||||
@@ -4370,6 +4600,7 @@ pub fn render_ui(
|
|||||||
.max_height(420.0)
|
.max_height(420.0)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let mut wanted_pdfs = Vec::new();
|
let mut wanted_pdfs = Vec::new();
|
||||||
|
let tile = page_tile_size(notebook);
|
||||||
egui::Grid::new("page_picker_grid")
|
egui::Grid::new("page_picker_grid")
|
||||||
.spacing([8.0, 8.0])
|
.spacing([8.0, 8.0])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
@@ -4383,6 +4614,7 @@ pub fn render_ui(
|
|||||||
page,
|
page,
|
||||||
idx + 1,
|
idx + 1,
|
||||||
is_current,
|
is_current,
|
||||||
|
tile,
|
||||||
&mut wanted_pdfs,
|
&mut wanted_pdfs,
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -4428,6 +4660,7 @@ pub fn render_ui(
|
|||||||
|
|
||||||
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
|
// Automatisches Schließen der Popover bei Klick außerhalb von Leiste und Popover
|
||||||
if (state.is_pen_menu_open
|
if (state.is_pen_menu_open
|
||||||
|
|| state.is_eraser_menu_open
|
||||||
|| state.is_highlighter_menu_open
|
|| state.is_highlighter_menu_open
|
||||||
|| state.is_text_color_menu_open
|
|| state.is_text_color_menu_open
|
||||||
|| state.is_more_menu_open
|
|| state.is_more_menu_open
|
||||||
@@ -4439,6 +4672,7 @@ pub fn render_ui(
|
|||||||
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
|
let in_popover = state.popover_rect.is_some_and(|r| r.contains(pos));
|
||||||
if !in_toolbar && !in_popover {
|
if !in_toolbar && !in_popover {
|
||||||
state.is_pen_menu_open = false;
|
state.is_pen_menu_open = false;
|
||||||
|
state.is_eraser_menu_open = false;
|
||||||
state.is_highlighter_menu_open = false;
|
state.is_highlighter_menu_open = false;
|
||||||
state.is_text_color_menu_open = false;
|
state.is_text_color_menu_open = false;
|
||||||
state.is_more_menu_open = false;
|
state.is_more_menu_open = false;
|
||||||
@@ -4847,6 +5081,7 @@ pub fn render_ui(
|
|||||||
let is_recommended = match updater.runtime() {
|
let is_recommended = match updater.runtime() {
|
||||||
crate::updater::RuntimeEnvironment::AppImage { .. } => is_appimage,
|
crate::updater::RuntimeEnvironment::AppImage { .. } => is_appimage,
|
||||||
crate::updater::RuntimeEnvironment::DebPackage { .. } => is_deb,
|
crate::updater::RuntimeEnvironment::DebPackage { .. } => is_deb,
|
||||||
|
crate::updater::RuntimeEnvironment::WindowsExe { .. } => asset.name.ends_with(".exe"),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4912,6 +5147,7 @@ pub fn render_ui(
|
|||||||
crate::updater::RuntimeEnvironment::DebPackage { .. } => t!("📦 Paket mit Software-Center öffnen / installieren"),
|
crate::updater::RuntimeEnvironment::DebPackage { .. } => t!("📦 Paket mit Software-Center öffnen / installieren"),
|
||||||
crate::updater::RuntimeEnvironment::PortableOrDev { .. }
|
crate::updater::RuntimeEnvironment::PortableOrDev { .. }
|
||||||
| crate::updater::RuntimeEnvironment::Flatpak => t!("📂 Im Dateimanager anzeigen"),
|
| crate::updater::RuntimeEnvironment::Flatpak => t!("📂 Im Dateimanager anzeigen"),
|
||||||
|
crate::updater::RuntimeEnvironment::WindowsExe { .. } => t!("🔄 Update installieren"),
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -5818,17 +6054,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_eraser_size_snaps_to_the_available_sizes() {
|
fn test_eraser_size_stays_in_the_slider_range() {
|
||||||
let mut state = UiState::new();
|
let mut state = UiState::new();
|
||||||
assert_eq!(state.eraser_radius(), 30.0);
|
assert_eq!(state.eraser_radius(), 30.0);
|
||||||
// Values from the settings file snap to the closest size
|
// Values from the settings file stay within the slider range
|
||||||
state.set_eraser_radius_mm(7.0);
|
state.set_eraser_radius_mm(7.0);
|
||||||
assert_eq!(state.eraser_radius_mm, 6.0);
|
assert_eq!(state.eraser_radius_mm, 7.0);
|
||||||
assert_eq!(state.eraser_size_index(), 2);
|
|
||||||
state.set_eraser_radius_mm(100.0);
|
state.set_eraser_radius_mm(100.0);
|
||||||
assert_eq!(state.eraser_radius_mm, 12.0);
|
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.1 / 2.0);
|
||||||
|
state.set_eraser_radius_mm(0.0);
|
||||||
|
assert_eq!(state.eraser_radius_mm, ERASER_DIAMETER_RANGE_MM.0 / 2.0);
|
||||||
state.set_eraser_radius_mm(f32::NAN);
|
state.set_eraser_radius_mm(f32::NAN);
|
||||||
assert!(ERASER_RADII_MM.contains(&state.eraser_radius_mm));
|
assert_eq!(state.eraser_radius_mm, 3.0);
|
||||||
|
assert_eq!(format_mm(3.0), "3");
|
||||||
|
assert_eq!(format_mm(2.5), "2.5");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5970,7 +6209,7 @@ mod tests {
|
|||||||
const UI_SOURCES: [(&str, &str); 4] = [
|
const UI_SOURCES: [(&str, &str); 4] = [
|
||||||
("ui.rs", include_str!("ui.rs")),
|
("ui.rs", include_str!("ui.rs")),
|
||||||
("selection.rs", include_str!("selection.rs")),
|
("selection.rs", include_str!("selection.rs")),
|
||||||
("main.rs", include_str!("main.rs")),
|
("app.rs", include_str!("app.rs")),
|
||||||
("file_picker.rs", include_str!("file_picker.rs")),
|
("file_picker.rs", include_str!("file_picker.rs")),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -6504,6 +6743,25 @@ mod tests {
|
|||||||
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
|
assert!(rect.contains_rect(shapes[1].visual_bounding_rect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_tiles_follow_the_page_shape() {
|
||||||
|
// Slides only: flat tiles without an empty strip below the page (Issue #40)
|
||||||
|
let mut notebook = Notebook::new();
|
||||||
|
notebook.pages[0].size = glam::Vec2::new(2970.0, 1671.0);
|
||||||
|
let flat = page_tile_size(¬ebook);
|
||||||
|
assert!((flat.y - PAGE_TILE_LABEL - flat.x * 1671.0 / 2970.0).abs() < 0.01);
|
||||||
|
let thumb =
|
||||||
|
egui::Rect::from_min_size(egui::Pos2::ZERO, flat - egui::vec2(0.0, PAGE_TILE_LABEL));
|
||||||
|
let sheet = thumbnail_sheet(notebook.pages[0].size, thumb);
|
||||||
|
assert!((sheet.height() - thumb.height()).abs() < 0.01);
|
||||||
|
|
||||||
|
// A portrait page among them: tiles as high as A4, slides at the top
|
||||||
|
let index = notebook.add_page(PageBackground::Blank);
|
||||||
|
notebook.pages[index].size = crate::document::A4_SIZE;
|
||||||
|
let tall = page_tile_size(¬ebook);
|
||||||
|
assert!((tall.y - PAGE_TILE_LABEL - tall.x * PAGE_HEIGHT / PAGE_WIDTH).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_picker_requests_loading_of_unloaded_pages() {
|
fn test_page_picker_requests_loading_of_unloaded_pages() {
|
||||||
let updater = crate::updater::UpdaterController::new();
|
let updater = crate::updater::UpdaterController::new();
|
||||||
|
|||||||
+80
-19
@@ -121,6 +121,8 @@ pub enum RuntimeEnvironment {
|
|||||||
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
|
/// Als Flatpak installiert: Updates kommen über die Software-Verwaltung, ink-notes lädt
|
||||||
/// selbst nichts herunter (Vorgabe von Flathub)
|
/// selbst nichts herunter (Vorgabe von Flathub)
|
||||||
Flatpak,
|
Flatpak,
|
||||||
|
/// Portable `.exe` on Windows (Issue #48), replaced in place like the AppImage
|
||||||
|
WindowsExe { exe_path: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RuntimeEnvironment {
|
impl RuntimeEnvironment {
|
||||||
@@ -132,8 +134,13 @@ impl RuntimeEnvironment {
|
|||||||
) {
|
) {
|
||||||
return RuntimeEnvironment::Flatpak;
|
return RuntimeEnvironment::Flatpak;
|
||||||
}
|
}
|
||||||
let appimage_var = std::env::var("APPIMAGE").ok();
|
|
||||||
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
|
let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes"));
|
||||||
|
if cfg!(windows) {
|
||||||
|
return RuntimeEnvironment::WindowsExe {
|
||||||
|
exe_path: current_exe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let appimage_var = std::env::var("APPIMAGE").ok();
|
||||||
Self::detect_from(appimage_var.as_deref(), ¤t_exe)
|
Self::detect_from(appimage_var.as_deref(), ¤t_exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +173,7 @@ impl RuntimeEnvironment {
|
|||||||
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
|
RuntimeEnvironment::DebPackage { .. } => t!("System-Paket (.deb)"),
|
||||||
RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
|
RuntimeEnvironment::PortableOrDev { .. } => t!("Entwicklung / Lokal"),
|
||||||
RuntimeEnvironment::Flatpak => "Flatpak",
|
RuntimeEnvironment::Flatpak => "Flatpak",
|
||||||
|
RuntimeEnvironment::WindowsExe { .. } => "Windows",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,18 +218,9 @@ pub fn sanitize_asset_name(raw_name: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
/// Liefert das isolierte Cache-Verzeichnis für Downloads pro Nutzer mit Rechten 0700.
|
/// Liefert das isolierte Cache-Verzeichnis für Downloads pro Nutzer mit Rechten 0700.
|
||||||
pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
||||||
let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
|
let dir = crate::paths::cache_dir()
|
||||||
let trimmed = xdg.trim();
|
.ok_or_else(|| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?
|
||||||
if !trimmed.is_empty() {
|
.join("updates");
|
||||||
PathBuf::from(trimmed)
|
|
||||||
} else {
|
|
||||||
default_user_cache_dir()?
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
default_user_cache_dir()?
|
|
||||||
};
|
|
||||||
|
|
||||||
let dir = base.join("ink-notes").join("updates");
|
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| {
|
std::fs::create_dir_all(&dir).map_err(|e| {
|
||||||
t!(
|
t!(
|
||||||
"Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
|
"Konnte Update-Cache-Verzeichnis nicht anlegen ({path}): {e}",
|
||||||
@@ -239,12 +238,6 @@ pub fn get_updates_cache_dir() -> Result<PathBuf, String> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_user_cache_dir() -> Result<PathBuf, String> {
|
|
||||||
let home = std::env::var("HOME")
|
|
||||||
.map_err(|_| t!("HOME-Umgebungsvariable ist nicht gesetzt.").to_string())?;
|
|
||||||
Ok(PathBuf::from(home).join(".cache"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Löscht alte temporäre Downloads aus dem Cache-Verzeichnis.
|
/// Löscht alte temporäre Downloads aus dem Cache-Verzeichnis.
|
||||||
pub fn cleanup_old_updates() {
|
pub fn cleanup_old_updates() {
|
||||||
if let Ok(dir) = get_updates_cache_dir() {
|
if let Ok(dir) = get_updates_cache_dir() {
|
||||||
@@ -551,6 +544,48 @@ fn http_get_with_auth_guard(
|
|||||||
Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string())
|
Err(t!("Zu viele Weiterleitungen (Redirect-Schleife abgebrochen).").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the running `.exe` with the downloaded one (Windows). A running program can't be
|
||||||
|
/// overwritten but can be renamed: the old one becomes `<name>.bak`, the new one takes its
|
||||||
|
/// place and starts with the next launch.
|
||||||
|
pub fn replace_running_exe(downloaded: &Path, exe_path: &Path) -> Result<(), String> {
|
||||||
|
let file_name = exe_path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| t!("Ungültiger Programmpfad.").to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let dir = exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| t!("Ungültiger Programmpfad.").to_string())?;
|
||||||
|
let new_file = dir.join(format!("{file_name}.new"));
|
||||||
|
let backup = dir.join(format!("{file_name}.bak"));
|
||||||
|
|
||||||
|
std::fs::copy(downloaded, &new_file).map_err(|e| {
|
||||||
|
let _ = std::fs::remove_file(&new_file);
|
||||||
|
t!("Konnte temporäre Update-Datei nicht schreiben: {e}", e = e)
|
||||||
|
})?;
|
||||||
|
// A backup of an earlier update is no longer running
|
||||||
|
let _ = std::fs::remove_file(&backup);
|
||||||
|
if let Err(e) = std::fs::rename(exe_path, &backup) {
|
||||||
|
let _ = std::fs::remove_file(&new_file);
|
||||||
|
return Err(t!(
|
||||||
|
"Konnte Backup ({path}) nicht anlegen: {e}. Update abgebrochen, altes Programm unverändert.",
|
||||||
|
path = backup.display(),
|
||||||
|
e = e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::rename(&new_file, exe_path) {
|
||||||
|
// Put the old program back
|
||||||
|
let _ = std::fs::rename(&backup, exe_path);
|
||||||
|
let _ = std::fs::remove_file(&new_file);
|
||||||
|
return Err(t!(
|
||||||
|
"Konnte Programm nicht ersetzen: {e}. Altes Programm bleibt unverändert.",
|
||||||
|
e = e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(downloaded);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Asynchroner Controller für Update-Prüfung und Asset-Download.
|
/// Asynchroner Controller für Update-Prüfung und Asset-Download.
|
||||||
pub struct UpdaterController {
|
pub struct UpdaterController {
|
||||||
config: UpdaterConfig,
|
config: UpdaterConfig,
|
||||||
@@ -915,6 +950,7 @@ impl UpdaterController {
|
|||||||
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
|
"Die Flatpak-Version wird über die Software-Verwaltung aktualisiert."
|
||||||
)
|
)
|
||||||
.to_string()),
|
.to_string()),
|
||||||
|
RuntimeEnvironment::WindowsExe { exe_path } => replace_running_exe(file_path, exe_path),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,6 +1138,31 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_running_exe() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let exe = dir.path().join("ink-notes.exe");
|
||||||
|
let downloaded = dir.path().join("ink-notes_9.9.9_x86_64.exe");
|
||||||
|
std::fs::write(&exe, b"old").unwrap();
|
||||||
|
std::fs::write(&downloaded, b"new").unwrap();
|
||||||
|
replace_running_exe(&downloaded, &exe).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&exe).unwrap(), b"new");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(dir.path().join("ink-notes.exe.bak")).unwrap(),
|
||||||
|
b"old"
|
||||||
|
);
|
||||||
|
assert!(!downloaded.exists());
|
||||||
|
|
||||||
|
// Again: the backup of the first update is replaced
|
||||||
|
std::fs::write(&downloaded, b"newer").unwrap();
|
||||||
|
replace_running_exe(&downloaded, &exe).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&exe).unwrap(), b"newer");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(dir.path().join("ink-notes.exe.bak")).unwrap(),
|
||||||
|
b"new"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_flatpak_detection_disables_self_update() {
|
fn test_flatpak_detection_disables_self_update() {
|
||||||
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
|
assert!(is_flatpak(Some("eu.pansi.ink-notes"), false));
|
||||||
|
|||||||
@@ -389,6 +389,14 @@ impl Fling {
|
|||||||
velocity
|
velocity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The glide continues from `now` (after [`Self::release`] with the time the finger was
|
||||||
|
/// lifted, which may lie a little in the past).
|
||||||
|
pub fn restart_clock(&mut self, now: std::time::Instant) {
|
||||||
|
if self.active {
|
||||||
|
self.last = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
/// Hält an (neue Berührung, Mausklick, Mausrad).
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
@@ -430,6 +438,25 @@ mod tests {
|
|||||||
PageStack::new(vec![A4; count], current)
|
PageStack::new(vec![A4; count], current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fling_measures_with_event_times_handled_late() {
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let ms = |n: u64| t0 + Duration::from_millis(n);
|
||||||
|
let mut fling = Fling::default();
|
||||||
|
// The finger moved until 100 ms and was lifted at 105 ms; the app only got to the
|
||||||
|
// lift 300 ms later, so the glide starts from there
|
||||||
|
for i in 0..=10 {
|
||||||
|
fling.track(Vec2::new(0.0, -30.0), ms(i * 10));
|
||||||
|
}
|
||||||
|
let velocity = fling.release(ms(105));
|
||||||
|
assert!(velocity.y < -2000.0, "{velocity:?}");
|
||||||
|
assert!(fling.is_active());
|
||||||
|
fling.restart_clock(ms(405));
|
||||||
|
let first = fling.step(ms(421)).unwrap();
|
||||||
|
assert!(first.y < -20.0 && first.y > -60.0, "{first:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_fling_glides_and_slows_down() {
|
fn test_fling_glides_and_slows_down() {
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|||||||
+708
@@ -0,0 +1,708 @@
|
|||||||
|
//! Import of Xournal++ (.xopp) and Xournal (.xoj) notebooks (Issue #47).
|
||||||
|
//!
|
||||||
|
//! Both formats are gzip-compressed XML: `<page>` elements with a `<background>` and
|
||||||
|
//! `<layer>`s holding `<stroke>`, `<text>` and `<image>` elements; coordinates are PDF points.
|
||||||
|
//! The import creates a new notebook: one page per Xournal page, all layers flattened in
|
||||||
|
//! order. PDF backgrounds are taken over if the PDF can still be found; LaTeX formulas
|
||||||
|
//! (`<teximage>`) have no counterpart and are skipped.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use quick_xml::events::{BytesStart, Event};
|
||||||
|
use quick_xml::XmlVersion;
|
||||||
|
|
||||||
|
use crate::document::{Notebook, PageBackground, PdfBackground};
|
||||||
|
use crate::import::{self, ImportReport, Imported};
|
||||||
|
use crate::storage::{NotebookManifest, StrokeData};
|
||||||
|
use crate::t;
|
||||||
|
use crate::text::TextFont;
|
||||||
|
|
||||||
|
/// Is the file by its name a Xournal++ or Xournal notebook?
|
||||||
|
pub fn is_xopp_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(|e| e.eq_ignore_ascii_case("xopp") || e.eq_ignore_ascii_case("xoj"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed Xournal document, still in PDF points.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct XoppDocument {
|
||||||
|
pub pages: Vec<XoppPage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct XoppPage {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub background: XoppBackground,
|
||||||
|
pub items: Vec<XoppItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub enum XoppBackground {
|
||||||
|
#[default]
|
||||||
|
Plain,
|
||||||
|
Lined,
|
||||||
|
Graph,
|
||||||
|
Dotted,
|
||||||
|
/// Page `page` (from 0) of a PDF; `file` is only given on the first PDF page
|
||||||
|
Pdf {
|
||||||
|
file: Option<String>,
|
||||||
|
page: u32,
|
||||||
|
},
|
||||||
|
/// Image file as background
|
||||||
|
Pixmap {
|
||||||
|
file: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum XoppItem {
|
||||||
|
Stroke {
|
||||||
|
highlighter: bool,
|
||||||
|
color: [f32; 4],
|
||||||
|
/// Nominal width, then optionally one width per point (or per segment in Xournal)
|
||||||
|
widths: Vec<f32>,
|
||||||
|
points: Vec<glam::Vec2>,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
mono: bool,
|
||||||
|
size: f32,
|
||||||
|
pos: glam::Vec2,
|
||||||
|
color: [f32; 4],
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
/// Embedded image (base64 PNG) with its rectangle (left, top, right, bottom)
|
||||||
|
Image { rect: [f32; 4], data: String },
|
||||||
|
/// Element without a counterpart (LaTeX formula)
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the (usually gzip-compressed) XML of a Xournal file.
|
||||||
|
pub fn decompress(bytes: &[u8]) -> Result<String, String> {
|
||||||
|
String::from_utf8(import::gunzip(bytes)?).map_err(|_| t!("Keine Xournal-Datei").to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attributes(e: &BytesStart) -> HashMap<String, String> {
|
||||||
|
e.attributes()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|a| {
|
||||||
|
let value = a.normalized_value(XmlVersion::Implicit1_0).ok()?;
|
||||||
|
Some((
|
||||||
|
String::from_utf8_lossy(a.key.as_ref()).into_owned(),
|
||||||
|
value.into_owned(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(attrs: &HashMap<String, String>, key: &str) -> Option<f32> {
|
||||||
|
attrs
|
||||||
|
.get(key)?
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Xournal colors: `#rrggbbaa` or one of the names of the original Xournal palette.
|
||||||
|
fn parse_color(value: &str) -> [f32; 4] {
|
||||||
|
let rgba: u32 = match value.trim() {
|
||||||
|
"black" => 0x000000ff,
|
||||||
|
"blue" => 0x3333ccff,
|
||||||
|
"red" => 0xff0000ff,
|
||||||
|
"green" => 0x008000ff,
|
||||||
|
"gray" => 0x808080ff,
|
||||||
|
"lightblue" => 0x00c0ffff,
|
||||||
|
"lightgreen" => 0x00ff00ff,
|
||||||
|
"magenta" => 0xff00ffff,
|
||||||
|
"orange" => 0xff8000ff,
|
||||||
|
"yellow" => 0xffff00ff,
|
||||||
|
"white" => 0xffffffff,
|
||||||
|
hex => {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
match (u32::from_str_radix(hex, 16), hex.len()) {
|
||||||
|
(Ok(v), 8) => v,
|
||||||
|
(Ok(v), 6) => (v << 8) | 0xff,
|
||||||
|
_ => 0x000000ff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
[
|
||||||
|
(rgba >> 24) as f32 / 255.0,
|
||||||
|
((rgba >> 16) & 0xff) as f32 / 255.0,
|
||||||
|
((rgba >> 8) & 0xff) as f32 / 255.0,
|
||||||
|
(rgba & 0xff) as f32 / 255.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numbers(text: &str) -> Vec<f32> {
|
||||||
|
text.split_ascii_whitespace()
|
||||||
|
.filter_map(|v| v.parse::<f32>().ok())
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Element whose text content is still being collected.
|
||||||
|
enum Open {
|
||||||
|
Stroke(HashMap<String, String>),
|
||||||
|
Text(HashMap<String, String>),
|
||||||
|
Image(HashMap<String, String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the XML of a Xournal file.
|
||||||
|
pub fn parse(xml: &str) -> Result<XoppDocument, String> {
|
||||||
|
let mut reader = quick_xml::Reader::from_str(xml);
|
||||||
|
let mut doc = XoppDocument::default();
|
||||||
|
let mut seen_root = false;
|
||||||
|
let mut open: Option<Open> = None;
|
||||||
|
let mut content = String::new();
|
||||||
|
loop {
|
||||||
|
let event = reader
|
||||||
|
.read_event()
|
||||||
|
.map_err(|e| t!("Datei beschädigt: {err}", err = e))?;
|
||||||
|
match event {
|
||||||
|
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||||
|
let is_start = matches!(event, Event::Start(_));
|
||||||
|
let attrs = attributes(e);
|
||||||
|
match e.name().as_ref() {
|
||||||
|
b"xournal" => seen_root = true,
|
||||||
|
b"page" => doc.pages.push(XoppPage {
|
||||||
|
width: number(&attrs, "width").unwrap_or(595.0).max(1.0),
|
||||||
|
height: number(&attrs, "height").unwrap_or(842.0).max(1.0),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
b"background" => {
|
||||||
|
if let Some(page) = doc.pages.last_mut() {
|
||||||
|
page.background = parse_background(&attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b"stroke" if is_start => open = Some(Open::Stroke(attrs)),
|
||||||
|
b"text" if is_start => open = Some(Open::Text(attrs)),
|
||||||
|
b"image" if is_start => open = Some(Open::Image(attrs)),
|
||||||
|
b"teximage" => {
|
||||||
|
if let Some(page) = doc.pages.last_mut() {
|
||||||
|
page.items.push(XoppItem::Unsupported);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
content.clear();
|
||||||
|
}
|
||||||
|
Event::Text(t) if open.is_some() => {
|
||||||
|
content.push_str(&t.decode().map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
Event::CData(t) if open.is_some() => {
|
||||||
|
content.push_str(&t.decode().map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
Event::GeneralRef(r) if open.is_some() => {
|
||||||
|
if let Ok(Some(c)) = r.resolve_char_ref() {
|
||||||
|
content.push(c);
|
||||||
|
} else if let Ok(name) = r.decode() {
|
||||||
|
if let Some(s) = quick_xml::escape::resolve_predefined_entity(&name) {
|
||||||
|
content.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(_) => {
|
||||||
|
if let (Some(element), Some(page)) = (open.take(), doc.pages.last_mut()) {
|
||||||
|
page.items.extend(finish_item(element, &content));
|
||||||
|
}
|
||||||
|
content.clear();
|
||||||
|
}
|
||||||
|
Event::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen_root {
|
||||||
|
return Err(t!("Keine Xournal-Datei").to_string());
|
||||||
|
}
|
||||||
|
Ok(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_background(attrs: &HashMap<String, String>) -> XoppBackground {
|
||||||
|
match attrs.get("type").map(String::as_str) {
|
||||||
|
Some("pdf") => {
|
||||||
|
// Xournal++ has written page numbers like "2ll"; only the digits count
|
||||||
|
let page = attrs
|
||||||
|
.get("pageno")
|
||||||
|
.map(|v| {
|
||||||
|
v.chars()
|
||||||
|
.take_while(char::is_ascii_digit)
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(1);
|
||||||
|
XoppBackground::Pdf {
|
||||||
|
file: attrs.get("filename").filter(|f| !f.is_empty()).cloned(),
|
||||||
|
page: page.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("pixmap") => match attrs.get("filename") {
|
||||||
|
Some(file) if !file.is_empty() => XoppBackground::Pixmap { file: file.clone() },
|
||||||
|
_ => XoppBackground::Plain,
|
||||||
|
},
|
||||||
|
_ => match attrs.get("style").map(String::as_str) {
|
||||||
|
Some("lined" | "ruled") => XoppBackground::Lined,
|
||||||
|
Some("graph" | "isograph") => XoppBackground::Graph,
|
||||||
|
Some("dotted" | "isodotted") => XoppBackground::Dotted,
|
||||||
|
_ => XoppBackground::Plain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_item(element: Open, content: &str) -> Option<XoppItem> {
|
||||||
|
match element {
|
||||||
|
Open::Stroke(attrs) => {
|
||||||
|
let coords = numbers(content);
|
||||||
|
let points: Vec<glam::Vec2> = coords
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&[x, y]| glam::Vec2::new(x, y))
|
||||||
|
.collect();
|
||||||
|
if points.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(XoppItem::Stroke {
|
||||||
|
highlighter: attrs.get("tool").is_some_and(|t| t == "highlighter"),
|
||||||
|
color: parse_color(attrs.get("color").map_or("black", String::as_str)),
|
||||||
|
widths: attrs.get("width").map(|w| numbers(w)).unwrap_or_default(),
|
||||||
|
points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Open::Text(attrs) => {
|
||||||
|
if content.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(XoppItem::Text {
|
||||||
|
mono: attrs
|
||||||
|
.get("font")
|
||||||
|
.is_some_and(|f| f.to_ascii_lowercase().contains("mono")),
|
||||||
|
size: number(&attrs, "size").unwrap_or(12.0).max(1.0),
|
||||||
|
pos: glam::Vec2::new(
|
||||||
|
number(&attrs, "x").unwrap_or(0.0),
|
||||||
|
number(&attrs, "y").unwrap_or(0.0),
|
||||||
|
),
|
||||||
|
color: parse_color(attrs.get("color").map_or("black", String::as_str)),
|
||||||
|
content: content.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Open::Image(attrs) => {
|
||||||
|
let rect = ["left", "top", "right", "bottom"].map(|k| number(&attrs, k));
|
||||||
|
match rect {
|
||||||
|
[Some(l), Some(t), Some(r), Some(b)] => Some(XoppItem::Image {
|
||||||
|
rect: [l, t, r, b],
|
||||||
|
data: content.to_string(),
|
||||||
|
}),
|
||||||
|
_ => Some(XoppItem::Unsupported),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Our stroke width and sample pressures for Xournal widths (nominal width, then optionally
|
||||||
|
/// one width per point, or per segment in the original Xournal).
|
||||||
|
fn stroke_widths(widths: &[f32], count: usize, scale: f32) -> (f32, Vec<f32>) {
|
||||||
|
let nominal = widths.first().copied().unwrap_or(1.0).max(0.01) * scale;
|
||||||
|
let per_point = widths.get(1..).unwrap_or_default();
|
||||||
|
if per_point.is_empty() || per_point.len() + 1 < count {
|
||||||
|
return (nominal, vec![0.5; count]);
|
||||||
|
}
|
||||||
|
let scaled: Vec<f32> = (0..count)
|
||||||
|
.map(|i| per_point[i.min(per_point.len() - 1)] * scale)
|
||||||
|
.collect();
|
||||||
|
import::fit_widths(&scaled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a referenced file: as given, next to the Xournal file, or as Xournal++ attachment
|
||||||
|
/// (`<file>.xopp.<name>`).
|
||||||
|
fn find_file(name: &str, xopp_path: &Path) -> Option<PathBuf> {
|
||||||
|
let given = PathBuf::from(name);
|
||||||
|
let dir = xopp_path.parent().unwrap_or(Path::new("."));
|
||||||
|
let mut candidates = vec![given.clone(), dir.join(&given)];
|
||||||
|
candidates.push(PathBuf::from(format!("{}.{name}", xopp_path.display())));
|
||||||
|
if let Some(file_name) = given.file_name() {
|
||||||
|
candidates.push(dir.join(file_name));
|
||||||
|
}
|
||||||
|
candidates.into_iter().find(|p| p.is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new notebook from a Xournal++/Xournal file.
|
||||||
|
pub fn create_notebook_from_xopp(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
xopp_path: &Path,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
|
||||||
|
let bytes = std::fs::read(xopp_path).map_err(|e| e.to_string())?;
|
||||||
|
let doc = parse(&decompress(&bytes)?)?;
|
||||||
|
if doc.pages.is_empty() {
|
||||||
|
return Err(t!("Die Datei enthält keine Seiten").to_string());
|
||||||
|
}
|
||||||
|
import::create_notebook(base_dir, title, |dir| convert(&doc, xopp_path, dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PDF used as background: asset name and page sizes in points.
|
||||||
|
struct PdfSource {
|
||||||
|
asset: String,
|
||||||
|
sizes: Vec<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(doc: &XoppDocument, xopp_path: &Path, notebook_dir: &Path) -> Result<Imported, String> {
|
||||||
|
let assets = crate::assets::AssetStore::new(notebook_dir);
|
||||||
|
let mut report = ImportReport::default();
|
||||||
|
let mut pdfs: HashMap<String, Option<PdfSource>> = HashMap::new();
|
||||||
|
let mut current_pdf: Option<String> = None;
|
||||||
|
let mut next_id: u64 = 1;
|
||||||
|
let mut pages = Vec::with_capacity(doc.pages.len());
|
||||||
|
|
||||||
|
for (index, xpage) in doc.pages.iter().enumerate() {
|
||||||
|
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
|
||||||
|
// aspect ratio, centred horizontally and aligned at the top
|
||||||
|
let size = crate::document::pdf_page_size(xpage.width, xpage.height);
|
||||||
|
let placement = crate::document::pdf_placement(xpage.width, xpage.height, size);
|
||||||
|
let [origin, top_right, _, bottom_left] = placement;
|
||||||
|
let scale =
|
||||||
|
((top_right.x - origin.x) / xpage.width).min((bottom_left.y - origin.y) / xpage.height);
|
||||||
|
let map = |p: glam::Vec2| origin + p * scale;
|
||||||
|
|
||||||
|
let mut background = PageBackground::Blank;
|
||||||
|
let mut pdf_background = None;
|
||||||
|
let mut strokes: Vec<StrokeData> = Vec::new();
|
||||||
|
let mut new_id = || {
|
||||||
|
next_id += 1;
|
||||||
|
next_id - 1
|
||||||
|
};
|
||||||
|
|
||||||
|
match &xpage.background {
|
||||||
|
XoppBackground::Plain => {}
|
||||||
|
XoppBackground::Lined => background = PageBackground::Lined,
|
||||||
|
XoppBackground::Graph => background = PageBackground::Grid5mm,
|
||||||
|
XoppBackground::Dotted => background = PageBackground::DotGrid,
|
||||||
|
XoppBackground::Pdf { file, page } => {
|
||||||
|
if let Some(file) = file {
|
||||||
|
current_pdf = Some(file.clone());
|
||||||
|
}
|
||||||
|
if let Some(file) = ¤t_pdf {
|
||||||
|
let source = pdfs
|
||||||
|
.entry(file.clone())
|
||||||
|
.or_insert_with(|| load_pdf(file, xopp_path, &assets));
|
||||||
|
match source {
|
||||||
|
Some(source) => {
|
||||||
|
if let Some(&(width_pt, height_pt)) = source.sizes.get(*page as usize) {
|
||||||
|
pdf_background = Some(PdfBackground {
|
||||||
|
asset: source.asset.clone(),
|
||||||
|
page: *page,
|
||||||
|
width_pt,
|
||||||
|
height_pt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => report.missing_pdf = Some(file.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XoppBackground::Pixmap { file } => {
|
||||||
|
let image = find_file(file, xopp_path)
|
||||||
|
.and_then(|p| std::fs::read(p).ok())
|
||||||
|
.and_then(|b| assets.import_bytes(&b).ok());
|
||||||
|
match image {
|
||||||
|
Some(image) => {
|
||||||
|
strokes.push(import::image_stroke(new_id(), image.asset, placement))
|
||||||
|
}
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &xpage.items {
|
||||||
|
match item {
|
||||||
|
XoppItem::Stroke {
|
||||||
|
highlighter,
|
||||||
|
color,
|
||||||
|
widths,
|
||||||
|
points,
|
||||||
|
} => {
|
||||||
|
let (width, pressures) = stroke_widths(widths, points.len(), scale);
|
||||||
|
let points: Vec<glam::Vec2> = points.iter().map(|&p| map(p)).collect();
|
||||||
|
strokes.push(import::ink_stroke(
|
||||||
|
new_id(),
|
||||||
|
*color,
|
||||||
|
*highlighter,
|
||||||
|
&points,
|
||||||
|
width,
|
||||||
|
&pressures,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
XoppItem::Text {
|
||||||
|
mono,
|
||||||
|
size,
|
||||||
|
pos,
|
||||||
|
color,
|
||||||
|
content,
|
||||||
|
} => {
|
||||||
|
let font = if *mono {
|
||||||
|
TextFont::Mono
|
||||||
|
} else {
|
||||||
|
TextFont::Sans
|
||||||
|
};
|
||||||
|
// Xournal doesn't wrap: as wide as the longest line
|
||||||
|
strokes.push(import::text_stroke(
|
||||||
|
new_id(),
|
||||||
|
content,
|
||||||
|
size * scale,
|
||||||
|
font,
|
||||||
|
map(*pos),
|
||||||
|
[glam::Vec2::X, glam::Vec2::Y],
|
||||||
|
None,
|
||||||
|
*color,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
XoppItem::Image { rect, data } => {
|
||||||
|
let compact: String =
|
||||||
|
data.chars().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||||
|
let image = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(compact)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| assets.import_bytes(&b).ok());
|
||||||
|
match image {
|
||||||
|
Some(image) => {
|
||||||
|
let [l, t, r, b] = *rect;
|
||||||
|
let corners = [
|
||||||
|
glam::Vec2::new(l, t),
|
||||||
|
glam::Vec2::new(r, t),
|
||||||
|
glam::Vec2::new(r, b),
|
||||||
|
glam::Vec2::new(l, b),
|
||||||
|
]
|
||||||
|
.map(map);
|
||||||
|
strokes.push(import::image_stroke(new_id(), image.asset, corners));
|
||||||
|
}
|
||||||
|
None => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XoppItem::Unsupported => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(import::page(
|
||||||
|
index as u64 + 1,
|
||||||
|
size,
|
||||||
|
background,
|
||||||
|
pdf_background,
|
||||||
|
strokes,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Imported {
|
||||||
|
pages,
|
||||||
|
next_stroke_id: next_id,
|
||||||
|
layout: None,
|
||||||
|
report,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_pdf(file: &str, xopp_path: &Path, assets: &crate::assets::AssetStore) -> Option<PdfSource> {
|
||||||
|
let bytes = std::fs::read(find_file(file, xopp_path)?).ok()?;
|
||||||
|
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
|
||||||
|
let asset = assets.import_pdf(&bytes).ok()?;
|
||||||
|
Some(PdfSource { asset, sizes })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::document::StrokeTool;
|
||||||
|
use crate::storage;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn gzip(xml: &str) -> Vec<u8> {
|
||||||
|
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
encoder.write_all(xml.as_bytes()).unwrap();
|
||||||
|
encoder.finish().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png_base64() -> String {
|
||||||
|
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(png)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE: &str = r##"<?xml version="1.0" standalone="no"?>
|
||||||
|
<xournal creator="Xournal++ 1.2.3" fileversion="4">
|
||||||
|
<title>Xournal++ document</title>
|
||||||
|
<page width="595.27559" height="841.88976">
|
||||||
|
<background type="solid" color="#ffffffff" style="lined"/>
|
||||||
|
<layer>
|
||||||
|
<stroke tool="pen" ts="0" fn="" color="#3333ccff" width="1.41 1.0 2.0 1.5" capStyle="round">10 20 30 40 50 60</stroke>
|
||||||
|
<stroke tool="highlighter" color="#ffff00ff" width="8.5">0 0 100 0</stroke>
|
||||||
|
</layer>
|
||||||
|
<layer>
|
||||||
|
<text font="Sans" size="12" x="72" y="100" color="#000000ff">Hallo & Welt
|
||||||
|
zweite Zeile</text>
|
||||||
|
<teximage text="x^2" left="0" top="0" right="10" bottom="10">AAAA</teximage>
|
||||||
|
</layer>
|
||||||
|
</page>
|
||||||
|
<page width="841.88976" height="595.27559">
|
||||||
|
<background type="pdf" domain="absolute" filename="/nirgends/skript.pdf" pageno="1ll"/>
|
||||||
|
<layer/>
|
||||||
|
</page>
|
||||||
|
<page width="841.88976" height="595.27559">
|
||||||
|
<background type="pdf" pageno="2ll"/>
|
||||||
|
<layer/>
|
||||||
|
</page>
|
||||||
|
</xournal>"##;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_xopp() {
|
||||||
|
let doc = parse(&decompress(&gzip(SAMPLE)).unwrap()).unwrap();
|
||||||
|
assert_eq!(doc.pages.len(), 3);
|
||||||
|
let first = &doc.pages[0];
|
||||||
|
assert_eq!(first.background, XoppBackground::Lined);
|
||||||
|
assert_eq!(first.items.len(), 4);
|
||||||
|
match &first.items[0] {
|
||||||
|
XoppItem::Stroke {
|
||||||
|
highlighter,
|
||||||
|
color,
|
||||||
|
widths,
|
||||||
|
points,
|
||||||
|
} => {
|
||||||
|
assert!(!highlighter);
|
||||||
|
assert_eq!(*color, [0.2, 0.2, 0.8, 1.0]);
|
||||||
|
assert_eq!(widths, &[1.41, 1.0, 2.0, 1.5]);
|
||||||
|
assert_eq!(points.len(), 3);
|
||||||
|
assert_eq!(points[2], glam::Vec2::new(50.0, 60.0));
|
||||||
|
}
|
||||||
|
other => panic!("expected a stroke, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
first.items[1],
|
||||||
|
XoppItem::Stroke {
|
||||||
|
highlighter: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
match &first.items[2] {
|
||||||
|
XoppItem::Text { content, size, .. } => {
|
||||||
|
assert_eq!(content, "Hallo & Welt\nzweite Zeile");
|
||||||
|
assert_eq!(*size, 12.0);
|
||||||
|
}
|
||||||
|
other => panic!("expected a text, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(first.items[3], XoppItem::Unsupported);
|
||||||
|
assert_eq!(
|
||||||
|
doc.pages[1].background,
|
||||||
|
XoppBackground::Pdf {
|
||||||
|
file: Some("/nirgends/skript.pdf".into()),
|
||||||
|
page: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
doc.pages[2].background,
|
||||||
|
XoppBackground::Pdf {
|
||||||
|
file: None,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rejects_other_files() {
|
||||||
|
assert!(parse("<svg></svg>").is_err());
|
||||||
|
assert!(decompress(&[0x1f, 0x8b, 1, 2, 3]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_colors() {
|
||||||
|
assert_eq!(parse_color("#ff000080")[3], 128.0 / 255.0);
|
||||||
|
assert_eq!(parse_color("red"), [1.0, 0.0, 0.0, 1.0]);
|
||||||
|
assert_eq!(parse_color("#00ff00"), [0.0, 1.0, 0.0, 1.0]);
|
||||||
|
assert_eq!(parse_color("Unsinn"), [0.0, 0.0, 0.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pressure_widths_map_to_our_width_model() {
|
||||||
|
// Without pressure: nominal width, medium pressure (drawn exactly that wide)
|
||||||
|
assert_eq!(stroke_widths(&[2.0], 3, 10.0), (20.0, vec![0.5; 3]));
|
||||||
|
// With pressure: the widest point is drawn exactly as wide as in Xournal++
|
||||||
|
let (width, pressures) = stroke_widths(&[1.0, 3.0, 1.5], 2, 10.0);
|
||||||
|
let drawn: Vec<f32> = pressures.iter().map(|p| width * (0.5 + p)).collect();
|
||||||
|
assert!((drawn[0] - 30.0).abs() < 1e-3);
|
||||||
|
assert!((drawn[1] - 15.0).abs() < 1e-3);
|
||||||
|
// Xournal: one width per segment, the last point repeats the last one
|
||||||
|
let (_, pressures) = stroke_widths(&[1.0, 2.0, 1.0], 3, 1.0);
|
||||||
|
assert_eq!(pressures.len(), 3);
|
||||||
|
assert_eq!(pressures[1], pressures[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_notebook_from_xopp() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)]);
|
||||||
|
std::fs::write(source_dir.path().join("skript.pdf"), &pdf).unwrap();
|
||||||
|
// The PDF moved next to the notebook; its image is embedded
|
||||||
|
let xml = SAMPLE.replace("<layer/>\n</page>\n</xournal>", &format!(
|
||||||
|
"<layer><image left=\"0\" top=\"0\" right=\"40\" bottom=\"20\">{}</image></layer>\n</page>\n</xournal>",
|
||||||
|
png_base64()
|
||||||
|
));
|
||||||
|
let path = source_dir.path().join("Mathe.xopp");
|
||||||
|
std::fs::write(&path, gzip(&xml)).unwrap();
|
||||||
|
|
||||||
|
let (notebook, manifest, dir, report) =
|
||||||
|
create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
assert_eq!(manifest.title, "Mathe");
|
||||||
|
assert_eq!(notebook.pages.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
report,
|
||||||
|
ImportReport {
|
||||||
|
skipped: 1,
|
||||||
|
missing_pdf: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let (mut reloaded, _) = storage::load_notebook(&dir).unwrap();
|
||||||
|
storage::load_all_pages(&dir, &mut reloaded).unwrap();
|
||||||
|
let first = &reloaded.pages[0];
|
||||||
|
assert_eq!(first.background, PageBackground::Lined);
|
||||||
|
assert_eq!(first.size, crate::document::A4_SIZE);
|
||||||
|
assert_eq!(first.strokes.len(), 3);
|
||||||
|
let pen = &first.strokes[0];
|
||||||
|
assert_eq!(pen.tool, StrokeTool::Pen);
|
||||||
|
// Points → page units (10 per mm): 10 pt = 35.3 units
|
||||||
|
assert!((pen.points[0].position.x - 35.28).abs() < 0.05);
|
||||||
|
assert!(!pen.vertices.is_empty());
|
||||||
|
let marker = &first.strokes[1];
|
||||||
|
assert_eq!(marker.tool, StrokeTool::Highlighter);
|
||||||
|
assert_eq!(marker.color[3], import::HIGHLIGHTER_ALPHA);
|
||||||
|
assert!(first.strokes[2].is_text());
|
||||||
|
|
||||||
|
for (i, page) in reloaded.pages[1..].iter().enumerate() {
|
||||||
|
let bg = page
|
||||||
|
.pdf_background
|
||||||
|
.as_ref()
|
||||||
|
.expect("PDF background missing");
|
||||||
|
assert_eq!(bg.page, i as u32);
|
||||||
|
assert!(page.size.x > page.size.y);
|
||||||
|
}
|
||||||
|
assert!(reloaded.pages[2].strokes[0].is_image());
|
||||||
|
// Stroke ids are unique and the notebook continues after them
|
||||||
|
assert!(reloaded.next_stroke_id > 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_missing_pdf_is_reported() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = source_dir.path().join("Mathe.xoj");
|
||||||
|
std::fs::write(&path, SAMPLE).unwrap();
|
||||||
|
let (notebook, _, _, report) =
|
||||||
|
create_notebook_from_xopp(temp_dir.path(), "Mathe", &path).unwrap();
|
||||||
|
assert_eq!(notebook.pages.len(), 3);
|
||||||
|
assert_eq!(report.missing_pdf.as_deref(), Some("/nirgends/skript.pdf"));
|
||||||
|
assert!(notebook.pages[1].pdf_background.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
@@ -14,6 +14,10 @@ docs of the original crate were removed.
|
|||||||
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
|
- `src/platform/wayland.rs`: `ignore_decoration_touches_until` lets ink-notes ignore touches on
|
||||||
the title bar while the pen is near the screen, so a resting palm does not move the window
|
the title bar while the pen is near the screen, so a resting palm does not move the window
|
||||||
(Issue #39).
|
(Issue #39).
|
||||||
|
- `src/platform/wayland.rs` and the touch handler: `take_touch_time` returns the compositor
|
||||||
|
timestamp of each touch event (Started, Moved, Ended), so ink-notes measures the swipe speed
|
||||||
|
for gliding with the real event times and not with the time it got around to handling them
|
||||||
|
(Issue #28).
|
||||||
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
- `Cargo.toml`: `[lints.rust] warnings = "allow"`, because a path dependency is not built with
|
||||||
capped lints like a crates.io dependency.
|
capped lints like a crates.io dependency.
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
|||||||
Vendored
+30
@@ -28,6 +28,36 @@ pub(crate) fn decoration_touches_ignored() -> bool {
|
|||||||
.is_some_and(|until| std::time::Instant::now() < until)
|
.is_some_and(|until| std::time::Instant::now() < until)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: compositor timestamps (ms) of the touch events not yet taken, in event order.
|
||||||
|
static TOUCH_TIMES: std::sync::Mutex<
|
||||||
|
std::collections::VecDeque<(u64, crate::event::TouchPhase, u32)>,
|
||||||
|
> = std::sync::Mutex::new(std::collections::VecDeque::new());
|
||||||
|
|
||||||
|
/// ink-notes patch: remembers the compositor time of a touch event that is about to be sent.
|
||||||
|
pub(crate) fn push_touch_time(id: u64, phase: crate::event::TouchPhase, time: u32) {
|
||||||
|
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Nobody takes them (e.g. an app that never asks): keep only the latest
|
||||||
|
if times.len() >= 256 {
|
||||||
|
times.pop_front();
|
||||||
|
}
|
||||||
|
times.push_back((id, phase, time));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ink-notes patch: compositor time in milliseconds (arbitrary origin, wraps around) of the
|
||||||
|
/// touch event with this id and phase (`Started`, `Moved` or `Ended`; `Cancelled` has none),
|
||||||
|
/// for the event currently being handled. Call it once for every such `WindowEvent::Touch`.
|
||||||
|
/// `None` on X11 or if the time is unknown.
|
||||||
|
pub fn take_touch_time(id: u64, phase: crate::event::TouchPhase) -> Option<u32> {
|
||||||
|
let mut times = TOUCH_TIMES.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Older entries that were never taken are skipped
|
||||||
|
while let Some((i, p, time)) = times.pop_front() {
|
||||||
|
if i == id && p == phase {
|
||||||
|
return Some(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
|
/// Additional methods on [`EventLoopWindowTarget`] that are specific to Wayland.
|
||||||
pub trait EventLoopWindowTargetExtWayland {
|
pub trait EventLoopWindowTargetExtWayland {
|
||||||
/// True if the [`EventLoopWindowTarget`] uses Wayland.
|
/// True if the [`EventLoopWindowTarget`] uses Wayland.
|
||||||
|
|||||||
+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()))
|
||||||
|
}
|
||||||
@@ -83,6 +83,9 @@ impl TouchHandler for WinitState {
|
|||||||
.touch_map
|
.touch_map
|
||||||
.insert(id, TouchPoint { surface, location, frame_of: None });
|
.insert(id, TouchPoint { surface, location, frame_of: None });
|
||||||
|
|
||||||
|
// ink-notes patch: keep the event time for `take_touch_time`
|
||||||
|
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Started, time);
|
||||||
|
|
||||||
self.events_sink.push_window_event(
|
self.events_sink.push_window_event(
|
||||||
WindowEvent::Touch(Touch {
|
WindowEvent::Touch(Touch {
|
||||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||||
@@ -138,6 +141,8 @@ impl TouchHandler for WinitState {
|
|||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ink-notes patch: keep the event time for `take_touch_time`
|
||||||
|
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Ended, time);
|
||||||
self.events_sink.push_window_event(
|
self.events_sink.push_window_event(
|
||||||
WindowEvent::Touch(Touch {
|
WindowEvent::Touch(Touch {
|
||||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||||
@@ -157,7 +162,7 @@ impl TouchHandler for WinitState {
|
|||||||
_: &Connection,
|
_: &Connection,
|
||||||
_: &QueueHandle<Self>,
|
_: &QueueHandle<Self>,
|
||||||
touch: &WlTouch,
|
touch: &WlTouch,
|
||||||
_: u32,
|
time: u32,
|
||||||
id: i32,
|
id: i32,
|
||||||
position: (f64, f64),
|
position: (f64, f64),
|
||||||
) {
|
) {
|
||||||
@@ -192,6 +197,9 @@ impl TouchHandler for WinitState {
|
|||||||
|
|
||||||
touch_point.location = LogicalPosition::<f64>::from(position);
|
touch_point.location = LogicalPosition::<f64>::from(position);
|
||||||
|
|
||||||
|
// ink-notes patch: keep the event time for `take_touch_time`
|
||||||
|
crate::platform::wayland::push_touch_time(id as u64, TouchPhase::Moved, time);
|
||||||
|
|
||||||
self.events_sink.push_window_event(
|
self.events_sink.push_window_event(
|
||||||
WindowEvent::Touch(Touch {
|
WindowEvent::Touch(Touch {
|
||||||
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
device_id: crate::event::DeviceId(crate::platform_impl::DeviceId::Wayland(
|
||||||
|
|||||||
Reference in new issue
Block a user