Compare commits
50
Commits
dfb5a64917
..
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a39276da4 | ||
|
|
b5c1da07fa | ||
|
|
c52e958c7f | ||
|
|
3e2b627d6a | ||
|
|
616fc57666 | ||
|
|
d150fd1883 | ||
|
|
f9ede537ed | ||
|
|
9571e2121c | ||
|
|
4a7627e461 | ||
|
|
cb11b1f828 | ||
|
|
89005f718e | ||
|
|
b5113cefa2 | ||
|
|
88ea7d2d53 | ||
|
|
7345c00568 | ||
|
|
f76fad92be | ||
|
|
2c60a03460 | ||
|
|
b6580f197d | ||
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 | ||
|
|
5cd45831e2 | ||
|
|
71f6a70829 | ||
|
|
b751b71223 | ||
|
|
afb057692d | ||
|
|
eeae143dc6 | ||
|
|
992c3daad7 | ||
|
|
9684238a53 | ||
|
|
e8674e730f | ||
|
|
7e99d59042 | ||
|
|
7ed971d2a2 | ||
|
|
5ce2c250bb | ||
|
|
d61e1819ed | ||
|
|
12755d8eea | ||
|
|
510035e807 | ||
|
|
1afe4a0f9d | ||
|
|
04666bf389 | ||
|
|
a82402bfce | ||
|
|
06b3aeb311 | ||
|
|
f9b39c6fe3 | ||
|
|
f9ebcb0296 | ||
|
|
df72981a9a | ||
|
|
5cb77b9af7 | ||
|
|
bb0faf424b | ||
|
|
3999996e98 | ||
|
|
3eb21dadc4 | ||
|
|
5780c266b7 | ||
|
|
91e2d78b4f |
No files matched your search
@@ -9,15 +9,25 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
name: Build Packages & Publish to Gitea
|
||||
# Hinweis zum Gitea act_runner:
|
||||
# Standardmäßig 'ubuntu-latest'. Falls Ihr Runner unter einem anderen Label registriert ist
|
||||
# (z. B. 'linux', 'ubuntu-24.04' oder 'host'), passen Sie dieses Label an.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Install System Packaging Tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
fi
|
||||
$SUDO apt-get update -y
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libx11-dev \
|
||||
@@ -31,20 +41,37 @@ jobs:
|
||||
python3
|
||||
|
||||
- name: Setup Rust Toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
|
||||
with:
|
||||
toolchain: stable
|
||||
# Die Action installiert mit --profile minimal; ohne diese Komponenten
|
||||
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
|
||||
components: rustfmt, clippy
|
||||
cache: true
|
||||
|
||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
|
||||
- name: Install rsign2 (Release-Signatur)
|
||||
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
|
||||
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
|
||||
|
||||
- name: Code Quality & Testing (Quality Gates)
|
||||
run: |
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
|
||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
|
||||
run: |
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/build-release.sh
|
||||
env:
|
||||
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
||||
|
||||
- name: Publish Release to Gitea
|
||||
run: |
|
||||
./scripts/publish-gitea-release.sh "${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}"
|
||||
./scripts/publish-gitea-release.sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_SERVER: ${{ github.server_url }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
Generated
+638
-13
@@ -79,6 +79,26 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -118,6 +138,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.5.3"
|
||||
@@ -151,6 +177,15 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-sys"
|
||||
version = "0.2.1"
|
||||
@@ -167,7 +202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68"
|
||||
dependencies = [
|
||||
"block-sys",
|
||||
"objc2",
|
||||
"objc2 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -207,6 +242,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -269,6 +310,21 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.11.1"
|
||||
@@ -279,6 +335,12 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "com"
|
||||
version = "0.6.0"
|
||||
@@ -375,6 +437,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -390,6 +461,22 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -403,7 +490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -413,12 +500,32 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
@@ -529,6 +636,18 @@ dependencies = [
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum_dispatch"
|
||||
version = "0.3.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.27.2"
|
||||
@@ -561,12 +680,33 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -580,7 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
@@ -620,6 +760,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -644,6 +794,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -654,6 +814,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -677,6 +848,16 @@ dependencies = [
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -747,7 +928,7 @@ dependencies = [
|
||||
"presser",
|
||||
"thiserror",
|
||||
"winapi",
|
||||
"windows",
|
||||
"windows 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -770,6 +951,17 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -801,6 +993,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -830,7 +1028,7 @@ checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch",
|
||||
"objc2",
|
||||
"objc2 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -937,6 +1135,35 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -949,18 +1176,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"fs2",
|
||||
"glam",
|
||||
"image",
|
||||
"minisign-verify",
|
||||
"naga",
|
||||
"octotablet",
|
||||
"pollster",
|
||||
"printpdf",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
"url",
|
||||
"webbrowser",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1199,6 +1436,22 @@ dependencies = [
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -1209,6 +1462,16 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -1331,7 +1594,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d"
|
||||
dependencies = [
|
||||
"objc-sys",
|
||||
"objc2-encode",
|
||||
"objc2-encode 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||
dependencies = [
|
||||
"objc2-encode 4.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1340,6 +1648,34 @@ version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_exception"
|
||||
version = "0.1.2"
|
||||
@@ -1349,6 +1685,25 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "octotablet"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c4b9470450ffa669cb3be55ef863dfd9592a06284cb291546363c514bb4aa3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg_aliases 0.2.2",
|
||||
"enum_dispatch",
|
||||
"raw-window-handle 0.6.2",
|
||||
"smallvec",
|
||||
"strum",
|
||||
"thiserror",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1436,6 +1791,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -1522,6 +1890,18 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -1609,6 +1989,20 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -1647,6 +2041,41 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -1687,6 +2116,12 @@ dependencies = [
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -1730,6 +2165,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -1830,6 +2276,34 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -1916,6 +2390,20 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
@@ -2041,6 +2529,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
@@ -2065,6 +2559,30 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -2099,6 +2617,12 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -2310,6 +2834,24 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -2324,7 +2866,7 @@ checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.1.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
@@ -2350,7 +2892,7 @@ dependencies = [
|
||||
"arrayvec",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.1.1",
|
||||
"codespan-reporting",
|
||||
"indexmap",
|
||||
"log",
|
||||
@@ -2379,7 +2921,7 @@ dependencies = [
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.1.1",
|
||||
"core-graphics-types",
|
||||
"d3d12",
|
||||
"glow",
|
||||
@@ -2466,7 +3008,19 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.52.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
@@ -2479,12 +3033,53 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
@@ -2503,6 +3098,15 @@ dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -2711,7 +3315,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"cfg_aliases",
|
||||
"cfg_aliases 0.1.1",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
@@ -2722,7 +3326,7 @@ dependencies = [
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk-sys",
|
||||
"objc2",
|
||||
"objc2 0.4.1",
|
||||
"once_cell",
|
||||
"orbclient",
|
||||
"percent-encoding",
|
||||
@@ -2895,6 +3499,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -2939,3 +3549,18 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
+20
-1
@@ -1,7 +1,12 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
rust-version = "1.75"
|
||||
|
||||
[dependencies]
|
||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||
@@ -29,6 +34,17 @@ egui = "0.27"
|
||||
egui-wgpu = "0.27"
|
||||
egui-winit = { version = "0.27", default-features = false, features = ["links"] }
|
||||
webbrowser = "0.8"
|
||||
semver = "1.0"
|
||||
sha2 = "0.10"
|
||||
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
|
||||
url = "2.5"
|
||||
octotablet = "0.1.0"
|
||||
|
||||
# Bilder einfügen: Dekodieren (PNG/JPEG/GIF/WebP/BMP) und Systemzwischenablage
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
|
||||
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
||||
minisign-verify = "0.3.0"
|
||||
fs2 = "0.4"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
@@ -37,6 +53,9 @@ opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = 3
|
||||
strip = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
# Prüft den WGSL-Shader in den Tests (dieselbe Version wie in wgpu 0.19)
|
||||
naga = { version = "0.19", features = ["wgsl-in"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Harald Pansi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -10,9 +10,10 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
||||
|
||||
### ⚡ Latenzoptimierte Inking-Pipeline
|
||||
- **Hardware-Rendering:** Direct-to-Screen-Rendering mit `wgpu` (Vulkan / X11 / Wayland) unter Nutzung von `PresentMode::Mailbox` für minimale Eingabeverzögerung.
|
||||
- **1€-Filter (One-Euro-Filter):** Dynamischer Tiefpassfilter zur Glättung von Zittern bei langsamen Bewegungen ohne spürbare Latenz bei schnellen Schwüngen.
|
||||
- **Zwei-Stufen-Modell:**
|
||||
1. *Live-Phase:* Eingehende Stylus-Samples werden in Echtzeit durch einen 1€-Filter (One-Euro-Filter) entprellt und inkrementell als GPU-Meshes dargestellt.
|
||||
2. *Commit-Phase (Post-Stroke Finalisierung):* Akausale Ecken-Erkennung (Corner Preservation bei Knicken > 65°), gewichtetes 3-Punkt-Druckfenster, Ramer-Douglas-Peucker (RDP) Dezimierung und saubere runde Endkappen.
|
||||
- **Centripetal Catmull-Rom Splines:** Weiche C1-stetige Kurveninterpolation ohne Überschwinger oder Schleifen.
|
||||
- **Drucksensitive Dreiecksstreifen-Tessellierung:** Rechnerische Generierung von Triangle-Meshes mit winkelhalbierenden Normalenvektoren entlang des Pfads.
|
||||
- **Werkzeuge:**
|
||||
- **Stift (Pen):** Feine, variable Strichstärken (0.3 mm bis 1.2 mm) mit Notiz-Farben (Schwarz, Blau, Rot, Grün, Grau).
|
||||
- **Textmarker (Highlighter):** Breite, halbtransparente Striche (4.0 mm bis 8.0 mm) mit Multiplikations-Blending.
|
||||
@@ -29,7 +30,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
||||
- **Geraden (Line):** Sehnen- zu Pfadlängenverhältnis > 0.92.
|
||||
- **Rechtecke (Rectangle):** 4 orthogonale Ecken via Douglas-Peucker-Polygonzug (RDP).
|
||||
- **Kreise & Ellipsen (Circle/Ellipse):** Geschlossene Pfade mit minimaler Varianz via Least-Squares Circle Fit.
|
||||
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit optionalen Richtungspfeilen.
|
||||
- **Koordinatensysteme (Axes):** Zwei orthogonale Achsen (80°–100° gesnappt auf exakt 90°) mit Richtungspfeilen.
|
||||
- **Dreiecke, Rhomben / Rauten & Oktaeder:** Regelmäßige Polygone mit Eckpunkt-Analyse.
|
||||
- **Interaktionsmodelle:**
|
||||
- **Draw-and-Hold:** Verweilt der Stift am Ende eines Strichs für > 400 ms regungslos (< 3 px), snappt die Skizze sofort automatisch in eine perfekte Vektorform ein.
|
||||
@@ -46,93 +47,158 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
|
||||
|
||||
### 📚 Multi-Dokumenten- & Notizbuch-Verwaltung
|
||||
- **Dateisystem-Architektur:** Jedes Dokument wird in einem separaten Verzeichnis unter `~/.local/share/ink-notes/notebooks/<id>/` mit `manifest.json` und Einzelseiten (`page_<id>.json`) gespeichert.
|
||||
- **Dokumenten-Manager:** Dunkles Glassmorphism-Modal direkt in der Toolbar:
|
||||
- Neues Notizbuch anlegen.
|
||||
- Zwischen Notizbüchern mit einem Klick wechseln.
|
||||
- Dokumente direkt umbenennen (`✏`).
|
||||
- Dokumente löschen (`🗑`).
|
||||
- **Dokumenten-Manager:** Toolbar-Modal zum Anlegen, Wechseln, Umbenennen und Löschen von Notizbüchern.
|
||||
- **Verlustfreier Wechsel:** Beim Dokumentenwechsel sichert ein synchroner Disk-Flush alle anstehenden Änderungen garantiert ab.
|
||||
|
||||
### 📄 Seiten & Hintergründe
|
||||
- **Virtuelles DIN-A4-Raster:** 2100 × 2970 Koordinateneinheiten (10 Einheiten = 1 mm).
|
||||
- **Hintergrundmuster:**
|
||||
- Blanko
|
||||
- Kariert (5 mm Raster)
|
||||
- Liniert (7.5 mm Zeilenabstand)
|
||||
- Punktraster (5 mm Dot-Grid)
|
||||
- **Hintergrundmuster:** Blanko, Kariert (5 mm), Liniert (7.5 mm), Punktraster (5 mm).
|
||||
- **Seitenverwaltung:** Vor- und Zurückblättern, neue Seiten anfügen, Seitensprung-Menü.
|
||||
|
||||
### 💾 Autosave & Persistenz
|
||||
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund-Thread nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
||||
- **Asynchroner Autosave-Controller:** Sichert Änderungen im Hintergrund nach 1.5 s Ruhezeit (Debounce), ohne das Zeichnen zu blockieren.
|
||||
- **Atomares Schreiben:** Temp-Dateien mit atomarem Rename (`manifest.json.tmp` -> `manifest.json`) schützen vor Datenkorruption bei Abstürzen.
|
||||
- **Sicherer Shutdown:** `ESC` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
|
||||
- **Dediziertes Speichern (`Strg + S`):** Sofortiger, synchroner Disk-Flush mit visueller Toast-Rückmeldung.
|
||||
- **Sicherer Shutdown:** `Strg + Q` oder Schließen des Fensters führt vor Programmende einen vollständigen synchronen Flush aus.
|
||||
|
||||
### 🖨️ Vektor-PDF-Export
|
||||
- Exportiert das gesamte Dokument via `printpdf` nach `./export.pdf`.
|
||||
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben.
|
||||
- Exportiert das Dokument via `printpdf` nach `~/Documents/<Titel>.pdf` (mit automatischer Kollisionsvermeidung `_1`, `_2`).
|
||||
- Striche und geometrische Formen werden als skalierbare Vektoren ausgegeben. Kürzel: `Strg + E`.
|
||||
|
||||
---
|
||||
|
||||
### 🚀 Release, Packaging & In-App Auto-Update (Zorin OS 18 / Ubuntu)
|
||||
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Noble-Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & 256x256 PNG, AppStream Metainfo).
|
||||
- **Portables .AppImage**: Eigenständiges, distributionsunabhängiges Bundle (x86_64) mit integrierten Laufzeitabhängigkeiten.
|
||||
- **In-App Gitea Updater (`src/updater.rs`)**:
|
||||
- Fragt im Hintergrund die Gitea Releases API (`https://gitea.pansi.eu/api/v1/repos/harald/ink-notes/releases/latest`) ab.
|
||||
- Zeigt bei neuen Versionen ein Update-Badge in der Toolbar an.
|
||||
- Release-Modal mit Versionsvergleich, Changelog und 1-Click-Download / In-Place-Aktualisierung.
|
||||
- **Gitea Actions CI/CD (`.gitea/workflows/release.yaml`)**: Automatisches Paketieren und Veröffentlichen auf Gitea bei Git-Tags (`v*`).
|
||||
## 📦 Release, Packaging & Updater
|
||||
|
||||
---
|
||||
|
||||
## 📦 Paketierung & Installation (Zorin OS 18)
|
||||
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
|
||||
- **Portables .AppImage**: Eigenständiges x86_64 Bundle.
|
||||
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
|
||||
|
||||
### 1. Release-Pakete bauen
|
||||
|
||||
```bash
|
||||
# Baut sowohl .deb als auch .AppImage und berechnet SHA256SUMS.txt in target/
|
||||
# Baut .deb, .AppImage und berechnet SHA256SUMS.txt in target/
|
||||
./scripts/build-release.sh
|
||||
```
|
||||
|
||||
Ergebnis in `target/`:
|
||||
- `ink-notes_0.1.0_amd64.deb` (~4.2 MB)
|
||||
- `ink-notes_0.1.0_x86_64.AppImage` (~5.6 MB)
|
||||
- `ink-notes_0.1.0_amd64.deb`
|
||||
- `ink-notes_0.1.0_x86_64.AppImage`
|
||||
- `SHA256SUMS.txt`
|
||||
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
|
||||
|
||||
### 2. Installation auf Zorin OS 18
|
||||
### Release-Signatur einrichten (einmalig)
|
||||
|
||||
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
|
||||
|
||||
```bash
|
||||
rsign generate -W -p ink-notes.pub -s ink-notes.key
|
||||
```
|
||||
|
||||
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
|
||||
|
||||
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
|
||||
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
|
||||
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
|
||||
|
||||
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
|
||||
|
||||
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
|
||||
|
||||
```bash
|
||||
# Natives Debian-Paket installieren
|
||||
sudo dpkg -i target/ink-notes_0.1.0_amd64.deb
|
||||
|
||||
# Oder portables AppImage direkt starten
|
||||
# Oder portables AppImage starten
|
||||
chmod +x target/ink-notes_0.1.0_x86_64.AppImage
|
||||
./target/ink-notes_0.1.0_x86_64.AppImage
|
||||
```
|
||||
|
||||
Nach der `.deb`-Installation ist `ink-notes` nahtlos im Zorin OS Anwendungsmenü unter *Büro* und *Grafik* integriert.
|
||||
#### Hinweise zu AppImage, glibc & FUSE:
|
||||
- **glibc-Mindestversion:** `ink-notes` benötigt `glibc >= 2.39` (Standard unter Ubuntu 24.04 LTS und Zorin OS 18).
|
||||
- **Host-Grafikstack:** Das AppImage bündelt die Anwendungslogik, nutzt für maximale Performance und Hardwarebeschleunigung jedoch den Treiber- und Grafikstack des Host-Systems (Vulkan ICD-Treiber, Wayland bzw. X11).
|
||||
- **FUSE 2 / FUSE 3 Kompatibilität:** Ubuntu 24.04-basierte Distributionen nutzen standardmäßig FUSE 3 (`fuse3`). Sollte das AppImage beim Start einen Fehler bezüglich `libfuse.so.2` ausgeben, kann entweder das Kompatibilitätspaket installiert werden:
|
||||
```bash
|
||||
sudo apt install libfuse2t64
|
||||
```
|
||||
oder das AppImage direkt ohne FUSE entpackt und ausgeführt werden:
|
||||
```bash
|
||||
./ink-notes_0.1.0_x86_64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### 3. Release auf Gitea veröffentlichen
|
||||
|
||||
Das Skript arbeitet vollkommen idempotent und aktualisiert bei wiederholter Ausführung Release-Notizen und Assets:
|
||||
|
||||
```bash
|
||||
# Veröffentlicht die Artefakte auf https://gitea.pansi.eu/harald/ink-notes
|
||||
./scripts/publish-gitea-release.sh <DEIN_GITEA_TOKEN>
|
||||
export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
|
||||
./scripts/publish-gitea-release.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Updater-Sicherheit
|
||||
|
||||
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
||||
|
||||
1. **Minisign-Signatur (Pflicht):**
|
||||
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
|
||||
- Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
|
||||
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
|
||||
2. **SHA-256-Integritätsprüfung:**
|
||||
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
|
||||
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
|
||||
3. **Redirect-Schutz & HTTPS:**
|
||||
- Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
|
||||
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
|
||||
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
|
||||
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
|
||||
- Asset-Namen werden sanitisiert: Pfadtrenner (`/`, `\`), Pfadüberquerungen (`..`), Steuerzeichen und leere Dateinamen werden strikt abgewiesen.
|
||||
5. **Atomarer AppImage-Tausch:**
|
||||
- Kein direktes Überschreiben laufender Programmdateien per `fs::copy` (verhindert den Linux-Kernel-Fehler *"Text file busy"*).
|
||||
- Die neue Datei wird temporär im selben Verzeichnis angelegt (`<name>.new.<pid>`), mit Ausführungsrechten (`0755`) versehen, das bestehende AppImage als `<name>.bak` gesichert (dessen Fehler nicht ignoriert werden) und anschließend per atomarem `fs::rename` ausgetauscht.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ CI-Runner einrichten (Gitea Actions / act_runner)
|
||||
|
||||
Der Veröffentlichungs-Workflow liegt unter [`.gitea/workflows/release.yaml`](.gitea/workflows/release.yaml).
|
||||
|
||||
### Runner-Label konfigurieren
|
||||
Der Workflow nutzt standardmäßig das Runner-Label:
|
||||
```yaml
|
||||
runs-on: ubuntu-latest
|
||||
```
|
||||
|
||||
In der Konfigurationsdatei Ihres Gitea `act_runner` (`config.yaml`) muss dieses Label auf ein passendes Container-Image oder den Host gemappt sein:
|
||||
```yaml
|
||||
runner:
|
||||
labels:
|
||||
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||
# oder für native Host-Ausführung:
|
||||
# - "ubuntu-latest:host"
|
||||
```
|
||||
|
||||
### Diagnose & Fehlerbehebung
|
||||
Bleibt ein Release-Job auf **"Waiting to run"** hängen:
|
||||
1. **Runner online?** Prüfen Sie in Gitea unter *Einstellungen -> Aktionen -> Runner*, ob der `act_runner` aktiv und mit einem grünen Punkt als *Online* markiert ist.
|
||||
2. **Label-Übereinstimmung:** Stimmen die im Runner registrierten Labels exakt mit dem `runs-on`-Feld in `.gitea/workflows/release.yaml` überein?
|
||||
3. **Rechte:** Der Workflow prüft `if [ "$(id -u)" -ne 0 ]` und setzt `sudo` nur ein, wenn der Runner nicht als `root` läuft. Das verhindert Fehler sowohl in unprivilegierten Host-Umgebungen als auch in Root-Containern.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Schnellstart (Entwicklung)
|
||||
|
||||
### Voraussetzungen
|
||||
- **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`)
|
||||
- **System-Bibliotheken (Linux):**
|
||||
- Vulkan-Treiber (z. B. `mesa-vulkan-drivers` oder proprietäre Treiber)
|
||||
- X11 / XWayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
|
||||
- X11 / Wayland Bibliotheken (`libx11-dev`, `libxcursor-dev`, `libxrandr-dev`, `libxi-dev`, `libxkbcommon-dev`)
|
||||
|
||||
### Bauen & Ausführen
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone gitea@farmersdog.pansi.eu:harald/ink-notes.git
|
||||
git clone https://gitea.pansi.eu/harald/ink-notes.git
|
||||
cd ink-notes
|
||||
|
||||
# Kompilieren und starten via Starter-Skript
|
||||
@@ -164,8 +230,10 @@ cargo run --release
|
||||
| **Nächste Seite** | `Bild ab (PageDown)` oder `N` |
|
||||
| **Vorherige Seite** | `Bild auf (PageUp)` |
|
||||
| **Erste / Letzte Seite** | `Pos 1 (Home)` / `Ende (End)` |
|
||||
| **PDF-Export** | `Strg + E` oder `Strg + S` |
|
||||
| **Beenden (mit Disk-Flush)** | `Esc` oder `Q` |
|
||||
| **Jetzt speichern (Disk Flush)** | `Strg + S` |
|
||||
| **Vektor-PDF-Export** | `Strg + E` |
|
||||
| **Dialoge / Menüs schließen** | `Esc` |
|
||||
| **Beenden (mit Disk-Flush)** | `Strg + Q` (oder Fenster schließen) |
|
||||
|
||||
---
|
||||
|
||||
@@ -179,7 +247,7 @@ src/
|
||||
├── viewport.rs # Kamera-Pan, Zoom, 2D-Transformationsmatrizen
|
||||
├── shader.wgsl # WGSL Vertex- & Fragment-Shader für Inking-Meshes
|
||||
├── filter.rs # One-Euro-Filter gegen Eingabe-Jitter
|
||||
├── spline.rs # Centripetal Catmull-Rom & Dreiecksstreifen-Tessellierung
|
||||
├── spline.rs # Catmull-Rom, Corner Preservation & Dreiecksstreifen
|
||||
├── geometry.rs # RDP-Algorithmus, Least-Squares Circle Fit, Shape Recognition
|
||||
├── selection.rs # Lasso-Auswahl, Bounding-Box, Griffe, Rotation & Skalierung
|
||||
├── background.rs # Tessellierung für Kariert, Liniert, Punktraster
|
||||
@@ -187,7 +255,7 @@ src/
|
||||
├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform)
|
||||
├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks
|
||||
├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal
|
||||
├── updater.rs # In-App Gitea Updater (SemVer, Release-API, Download, Self-Update)
|
||||
├── updater.rs # In-App Gitea Updater (SemVer, HTTPS, SHA256-Integrität, Self-Update)
|
||||
├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf
|
||||
└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui)
|
||||
```
|
||||
@@ -198,16 +266,16 @@ Ausführliche Details zur Architektur und mathematischen Modellen findest du unt
|
||||
|
||||
## 🧪 Tests ausführen
|
||||
|
||||
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit Unit-Tests abgesichert:
|
||||
Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit umfassenden Unit-Tests abgesichert:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
Alle 61 Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz und Versionsvergleiche konsistent ab.
|
||||
Alle Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz, Updater-Sicherheit und Versionsvergleiche konsistent ab.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
MIT License / Open Source Inking Project for Linux.
|
||||
Dieses Projekt ist unter der [MIT-Lizenz](LICENSE) lizenziert (Copyright © 2026 Harald Pansi).
|
||||
+54
-51
@@ -1,6 +1,6 @@
|
||||
# Dateiformat & Speicherstruktur von ink-notes
|
||||
|
||||
Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
||||
Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert:
|
||||
└── notebooks/
|
||||
├── <notebook_id_1>/
|
||||
│ ├── manifest.json
|
||||
│ ├── manifest.json.v0.bak (optional, Migrations-Backup)
|
||||
│ ├── page_1.json
|
||||
│ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern)
|
||||
│ ├── page_2.json
|
||||
│ └── ...
|
||||
└── <notebook_id_2>/
|
||||
@@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
||||
|
||||
```json
|
||||
{
|
||||
"format_version": 2,
|
||||
"id": "notebook-a1b2c3d4",
|
||||
"title": "Mathematik Vorlesung",
|
||||
"version": 1,
|
||||
"pages": [
|
||||
{
|
||||
"id": 1,
|
||||
"file_name": "page_1.json"
|
||||
"background": "Grid5mm"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"file_name": "page_2.json"
|
||||
"background": "DotGrid"
|
||||
}
|
||||
],
|
||||
"page_ids": [1, 2],
|
||||
"current_page_index": 0,
|
||||
"next_page_id": 3,
|
||||
"next_stroke_id": 42,
|
||||
@@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
||||
```
|
||||
|
||||
### Felder:
|
||||
- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`).
|
||||
- `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`).
|
||||
- `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs.
|
||||
- `version`: Format-Versionsnummer (aktuell `1`).
|
||||
- `pages`: Geordnete Liste von Seitenreferenzen.
|
||||
- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`).
|
||||
- `page_ids`: Abwärtskompatible Liste der Seiten-IDs.
|
||||
- `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore.
|
||||
- `next_page_id`: Inkrementierender ID-Zähler für neue Seiten.
|
||||
- `next_stroke_id`: Globaler ID-Zähler für neue Striche.
|
||||
@@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge
|
||||
## 3. Seitendatei (`page_<id>.json`)
|
||||
|
||||
Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen.
|
||||
Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"background": "Grid5mm",
|
||||
"strokes": [
|
||||
{
|
||||
"id": 1,
|
||||
"color": [0.12, 0.12, 0.14, 1.0],
|
||||
"width_mm": 0.5,
|
||||
"samples": [
|
||||
{
|
||||
"x": 350.2,
|
||||
"y": 420.5,
|
||||
"pressure": 0.65,
|
||||
"timestamp": 12.345
|
||||
},
|
||||
{
|
||||
"x": 352.1,
|
||||
"y": 425.8,
|
||||
"pressure": 0.70,
|
||||
"timestamp": 12.355
|
||||
}
|
||||
],
|
||||
"recognized_shape": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"color": [0.15, 0.35, 0.85, 1.0],
|
||||
"width_mm": 0.8,
|
||||
"samples": [],
|
||||
"recognized_shape": {
|
||||
"Rectangle": {
|
||||
"top_left": { "x": 500.0, "y": 600.0 },
|
||||
"width": 300.0,
|
||||
"height": 200.0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]}
|
||||
```
|
||||
|
||||
### Felder:
|
||||
- `format_version`: Format-Versionsnummer (`2`).
|
||||
- `id`: Eindeutige ID der Seite.
|
||||
- `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`).
|
||||
- `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen.
|
||||
- `id`: Eindeutige ID des Strichs.
|
||||
- `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`.
|
||||
- `width_mm`: Basis-Strichbreite in Millimetern.
|
||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden).
|
||||
- `recognized_shape`: Optionales Enum für geometrische Vektorformen:
|
||||
- `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden).
|
||||
- `shape`: Optionales Enum für geometrische Vektorformen:
|
||||
- `Line { start, end }`
|
||||
- `Rectangle { top_left, width, height }`
|
||||
- `Circle { center, radius }`
|
||||
@@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle
|
||||
- `Triangle { p1, p2, p3 }`
|
||||
- `Rhombus { center, width, height }`
|
||||
- `Octahedron { center, radius }`
|
||||
- `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`).
|
||||
- `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker).
|
||||
- `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben.
|
||||
|
||||
---
|
||||
|
||||
## 4. Atomares Schreibverfahren
|
||||
## 4. Datensicherheit & Migration
|
||||
|
||||
Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern:
|
||||
1. Daten werden in `<ziel>.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben.
|
||||
### Atomares Schreiben & Verzeichnis-Sync
|
||||
1. Daten werden in `<ziel>.tmp.<pid>.<nanos>` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix).
|
||||
2. Mittels `std::fs::rename("<ziel>.tmp", "<ziel>")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt.
|
||||
3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`.
|
||||
|
||||
### Quarantäne bei Parsefehlern
|
||||
Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_<id>.json.corrupt-<unix_ts>` verschoben. Die Ersatzseite wird als schreibgeschützt markiert.
|
||||
|
||||
### Migrations-Backups & Rolling Backups
|
||||
Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`).
|
||||
Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird.
|
||||
|
||||
---
|
||||
|
||||
## 5. Versionshistorie
|
||||
|
||||
| Version | Status | Beschreibung |
|
||||
|---|---|---|
|
||||
| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. |
|
||||
| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_<id>.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec<PageMeta>`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. |
|
||||
| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`)
|
||||
|
||||
Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert:
|
||||
- **Vorteile**:
|
||||
- Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON.
|
||||
- Minimal schnellere Serialisierung auf leistungsschwachen Systemen.
|
||||
- **Nachteile**:
|
||||
- Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren).
|
||||
- Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms.
|
||||
- Kompaktes JSON spart bereits über 53 % Dateigröße ein.
|
||||
- **Entscheidung**:
|
||||
Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt.
|
||||
@@ -0,0 +1,18 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=ink-notes
|
||||
GenericName=Notizbuch- & Inking-App
|
||||
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
||||
Exec=ink-notes %F
|
||||
Icon=eu.pansi.ink-notes
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Graphics;Utility;
|
||||
Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector;
|
||||
StartupWMClass=ink-notes
|
||||
MimeType=application/x-inknotes;
|
||||
Actions=NewWindow;
|
||||
|
||||
[Desktop Action NewWindow]
|
||||
Name=Neues Fenster
|
||||
Exec=ink-notes
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>eu.pansi.ink-notes</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>ink-notes</name>
|
||||
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
|
||||
<description>
|
||||
<p>
|
||||
ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux,
|
||||
geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus.
|
||||
</p>
|
||||
<p>Hauptmerkmale:</p>
|
||||
<ul>
|
||||
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
|
||||
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
|
||||
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
|
||||
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
|
||||
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
|
||||
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||
<provides>
|
||||
<binary>ink-notes</binary>
|
||||
</provides>
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Graphics</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
<developer id="eu.pansi">
|
||||
<name>Harald</name>
|
||||
</developer>
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<content_rating type="oars-1.1" />
|
||||
</component>
|
||||
@@ -4,7 +4,7 @@ Name=ink-notes
|
||||
GenericName=Notizbuch- & Inking-App
|
||||
Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung
|
||||
Exec=ink-notes %F
|
||||
Icon=ink-notes
|
||||
Icon=eu.pansi.ink-notes
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Office;Graphics;Utility;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">ink-notes.desktop</launchable>
|
||||
<launchable type="desktop-id">eu.pansi.ink-notes.desktop</launchable>
|
||||
<provides>
|
||||
<binary>ink-notes</binary>
|
||||
</provides>
|
||||
@@ -32,7 +32,7 @@
|
||||
<developer id="eu.pansi">
|
||||
<name>Harald</name>
|
||||
</developer>
|
||||
<url type="homepage">https://farmersdog.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://farmersdog.pansi.eu/harald/ink-notes/issues</url>
|
||||
<url type="homepage">https://gitea.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://gitea.pansi.eu/harald/ink-notes/issues</url>
|
||||
<content_rating type="oars-1.1" />
|
||||
</component>
|
||||
@@ -0,0 +1,35 @@
|
||||
## ink-notes {{TAG_NAME}}
|
||||
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Bilder einfügen**: Screenshots per Strg+V, Bilddateien über die eingebaute Dateiauswahl (Strg+I) oder per Drag & Drop; verschieben, drehen, skalieren und darauf schreiben – auch im PDF-Export (Dateiformat-Version 4: ältere Versionen öffnen solche Notizbücher nicht)
|
||||
- **Dunkelmodus & Papierfarben**: Weiß, Creme, Dunkelgrau oder Schwarz; auf dunklem Papier werden dunkle Stiftfarben automatisch aufgehellt (nur Anzeige – Dateien und PDF-Export bleiben unverändert)
|
||||
- **Nur Textmarker radieren**: Markierungen korrigieren, ohne die Handschrift darunter zu löschen – auch mit Stiftrückseite und Seitentaste
|
||||
- **Einstellbare Druckkurve**: Kennlinie weich bis hart und Mindestdruck mit Live-Vorschau, passend für jeden Digitizer
|
||||
- **Neue Formen**: Ellipsen sowie in einem Zug gezeichnete Pfeile und Doppelpfeile werden erkannt (Dateiformat-Version 3: ältere Versionen öffnen solche Notizbücher nicht)
|
||||
- **Stabiler Start**: Kein Absturz mehr, wenn die Grafikkarte nicht nutzbar ist (z. B. fehlende Rechte auf /dev/dri); Software-Renderer als Ausweichlösung und verständliche Fehlermeldung
|
||||
- **WSL (WSLg)**: Läuft unter WSL automatisch über X11 statt Wayland, da WSLg die Wayland-Verbindung abbricht; übersteuerbar mit `INK_NOTES_DISPLAY=x11|wayland`
|
||||
- **Wichtige Fehlerbehebung**: Nach einem Neustart auf einer späteren Seite konnte Weiterschreiben den bisherigen Inhalt dieser Seite überschreiben (betrifft v0.2.3 – v0.3.2)
|
||||
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl mit Vorschaubildern, größere Bedienelemente in allen Popovern
|
||||
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Pfeile & Doppelpfeile, Rechtecke, Kreise, Ellipsen, Dreiecke, Rauten, Vielecke & Koordinatensysteme mit spitzen Pfeilspitzen (Draw-and-Hold); Freiformen bleiben unverändert
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
|
||||
- **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
|
||||
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
|
||||
|
||||
### Installation:
|
||||
- **Zorin OS / Ubuntu / Debian (.deb)**:
|
||||
```bash
|
||||
sudo dpkg -i ink-notes_{{VERSION}}_amd64.deb
|
||||
```
|
||||
- **Portables AppImage (Alle Linux-Distributionen)**:
|
||||
```bash
|
||||
chmod +x ink-notes_{{VERSION}}_x86_64.AppImage
|
||||
./ink-notes_{{VERSION}}_x86_64.AppImage
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
|
||||
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/
|
||||
# Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
||||
cd "${ROOT_DIR}/target"
|
||||
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
||||
|
||||
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
|
||||
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
|
||||
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
|
||||
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
|
||||
rm -f "${SIGNATURE_FILE}"
|
||||
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
|
||||
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
|
||||
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
|
||||
if [ -z "${EMBEDDED_PUBKEY}" ]; then
|
||||
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v rsign >/dev/null 2>&1; then
|
||||
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
|
||||
KEY_FILE="$(mktemp)"
|
||||
trap 'rm -f "${KEY_FILE}"' EXIT
|
||||
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
|
||||
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
|
||||
rm -f "${KEY_FILE}"
|
||||
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
|
||||
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
|
||||
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|
||||
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
|
||||
echo "${VERIFY_OUT}"
|
||||
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
|
||||
rm -f "${SIGNATURE_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
echo "${VERIFY_OUT}"
|
||||
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
|
||||
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
|
||||
exit 1
|
||||
else
|
||||
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
|
||||
echo " Installierte Apps lehnen ein solches Release als Update ab."
|
||||
fi
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
||||
echo "======================================================================"
|
||||
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
||||
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
||||
echo ""
|
||||
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
||||
cat "${CHECKSUM_FILE}"
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# scripts/crash-test.sh
|
||||
# Stresstest für ink-notes Speicher- und Autosave-Robustheit.
|
||||
# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen
|
||||
# und verifiziert die Integrität der Notizbücher und Seiten.
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
ITERATIONS="${1:-200}"
|
||||
TEST_DIR="${REPO_ROOT}/target/crash_test_workspace"
|
||||
CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim"
|
||||
|
||||
echo "=================================================================="
|
||||
echo " ink-notes Crash- & Datenverlust-Stresstest"
|
||||
echo " Iterationen: ${ITERATIONS}"
|
||||
echo " Testverzeichnis: ${TEST_DIR}"
|
||||
echo "=================================================================="
|
||||
|
||||
# 1. Sicherstellen, dass das Test-Binary gebaut ist
|
||||
if [ ! -f "${CRASH_SIM_BIN}" ]; then
|
||||
echo "Baue crash_sim Release-Binary..."
|
||||
cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml"
|
||||
fi
|
||||
|
||||
# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren
|
||||
rm -rf "${TEST_DIR}"
|
||||
mkdir -p "${TEST_DIR}"
|
||||
"${CRASH_SIM_BIN}" init "${TEST_DIR}"
|
||||
|
||||
SUCCESS_COUNT=0
|
||||
INTERRUPTED_WRITES=0
|
||||
|
||||
trap 'rm -rf "${TEST_DIR}"' EXIT
|
||||
|
||||
echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..."
|
||||
|
||||
for i in $(seq 1 "${ITERATIONS}"); do
|
||||
# Worker im Hintergrund starten
|
||||
"${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 &
|
||||
WORKER_PID=$!
|
||||
|
||||
# Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen
|
||||
# RANDOM % 33 + 2 ergibt 2..34 Millisekunden
|
||||
MS_DELAY=$(( (RANDOM % 33) + 2 ))
|
||||
DELAY="0.$(printf "%03d" "$MS_DELAY")"
|
||||
sleep "${DELAY}"
|
||||
|
||||
# Harter Prozessabbruch ohne Clean-up (SIGKILL)
|
||||
if kill -9 "${WORKER_PID}" 2>/dev/null; then
|
||||
INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1))
|
||||
fi
|
||||
wait "${WORKER_PID}" 2>/dev/null || true
|
||||
|
||||
# Integritäts- und Konsistenzprüfung
|
||||
if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "❌ FEHLER in Iteration ${i}!"
|
||||
echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!"
|
||||
"${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true
|
||||
trap - EXIT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||
if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then
|
||||
echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})."
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=================================================================="
|
||||
echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!"
|
||||
echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}"
|
||||
echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}"
|
||||
echo " - Keine korrupten JSON-Dateien ohne Quarantäne"
|
||||
echo " - Keine unvollständigen Atom-Schreibungen"
|
||||
echo "=================================================================="
|
||||
+48
-11
@@ -18,11 +18,22 @@ APPDIR="${ROOT_DIR}/target/AppDir"
|
||||
CACHE_DIR="${ROOT_DIR}/target/tools"
|
||||
APPIMAGETOOL="${CACHE_DIR}/appimagetool-x86_64.AppImage"
|
||||
|
||||
# Pinned appimagetool Release & SHA-256 Hash
|
||||
APPIMAGETOOL_VERSION="1.9.1"
|
||||
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage"
|
||||
APPIMAGETOOL_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||
|
||||
echo "=== [1/5] Stelle Release-Binary sicher (ink-notes v${VERSION}) ==="
|
||||
if [ ! -f "target/release/ink-notes" ]; then
|
||||
cargo build --release
|
||||
fi
|
||||
|
||||
# Prüfe glibc-Mindestversion
|
||||
echo "=== Prüfe glibc-Symbolversionen ==="
|
||||
MAX_GLIBC=$(objdump -T "target/release/ink-notes" | grep "GLIBC_" | sed -e 's/.*GLIBC_//' | cut -d')' -f1 | sort -V | tail -n 1 || echo "unknown")
|
||||
echo "Höchste benötigte glibc-Symbolversion: GLIBC_${MAX_GLIBC}"
|
||||
echo "Hinweis: ink-notes nutzt den Host-Grafikstack (Vulkan ICD, Wayland/X11) und glibc >= ${MAX_GLIBC}."
|
||||
|
||||
# Sicherstellen, dass das PNG-Icon existiert
|
||||
if [ ! -f "assets/ink-notes.png" ]; then
|
||||
echo "Generiere PNG-Icon..."
|
||||
@@ -41,15 +52,20 @@ mkdir -p "${APPDIR}/usr/share/metainfo"
|
||||
cp "target/release/ink-notes" "${APPDIR}/usr/bin/ink-notes"
|
||||
chmod 755 "${APPDIR}/usr/bin/ink-notes"
|
||||
|
||||
cp "packaging/ink-notes.desktop" "${APPDIR}/usr/share/applications/ink-notes.desktop"
|
||||
cp "packaging/ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
|
||||
# Desktop-Datei
|
||||
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
|
||||
cp "packaging/eu.pansi.ink-notes.desktop" "${APPDIR}/eu.pansi.ink-notes.desktop"
|
||||
ln -sf "eu.pansi.ink-notes.desktop" "${APPDIR}/ink-notes.desktop"
|
||||
|
||||
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
||||
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
||||
cp "assets/ink-notes.png" "${APPDIR}/ink-notes.png"
|
||||
ln -sf "ink-notes.png" "${APPDIR}/.DirIcon"
|
||||
# Icons
|
||||
cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
|
||||
cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
|
||||
cp "assets/ink-notes.png" "${APPDIR}/eu.pansi.ink-notes.png"
|
||||
ln -sf "eu.pansi.ink-notes.png" "${APPDIR}/.DirIcon"
|
||||
|
||||
cp "packaging/ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||
# Metainfo
|
||||
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.appdata.xml"
|
||||
|
||||
# AppRun Skript erstellen
|
||||
cat <<'EOF' > "${APPDIR}/AppRun"
|
||||
@@ -62,11 +78,32 @@ exec "${SELF_DIR}/usr/bin/ink-notes" "$@"
|
||||
EOF
|
||||
chmod 755 "${APPDIR}/AppRun"
|
||||
|
||||
echo "=== [3/5] Hole appimagetool (gecacht in ${CACHE_DIR}) ==="
|
||||
echo "=== [3/5] Hole & verifiziere gepinntes appimagetool v${APPIMAGETOOL_VERSION} ==="
|
||||
mkdir -p "${CACHE_DIR}"
|
||||
if [ ! -f "${APPIMAGETOOL}" ]; then
|
||||
echo "Lade appimagetool herunter..."
|
||||
curl -fsSL -o "${APPIMAGETOOL}" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
|
||||
need_download=true
|
||||
if [ -f "${APPIMAGETOOL}" ]; then
|
||||
CURRENT_HASH=$(sha256sum "${APPIMAGETOOL}" | cut -d' ' -f1)
|
||||
if [ "${CURRENT_HASH}" = "${APPIMAGETOOL_SHA256}" ]; then
|
||||
need_download=false
|
||||
else
|
||||
echo "Gecachtes appimagetool hat ungültigen Hash. Lade neu herunter..."
|
||||
rm -f "${APPIMAGETOOL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${need_download}" = true ]; then
|
||||
echo "Lade appimagetool v${APPIMAGETOOL_VERSION} herunter..."
|
||||
curl -fsSL -o "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL_URL}"
|
||||
DOWNLOAD_HASH=$(sha256sum "${APPIMAGETOOL}.tmp" | cut -d' ' -f1)
|
||||
if [ "${DOWNLOAD_HASH}" != "${APPIMAGETOOL_SHA256}" ]; then
|
||||
echo "FEHLER: SHA256-Prüfsumme für appimagetool stimmt nicht überein!"
|
||||
echo "Erwartet: ${APPIMAGETOOL_SHA256}"
|
||||
echo "Erhalten: ${DOWNLOAD_HASH}"
|
||||
rm -f "${APPIMAGETOOL}.tmp"
|
||||
exit 1
|
||||
fi
|
||||
mv "${APPIMAGETOOL}.tmp" "${APPIMAGETOOL}"
|
||||
chmod +x "${APPIMAGETOOL}"
|
||||
fi
|
||||
|
||||
|
||||
+96
-20
@@ -16,7 +16,7 @@ ARCH="amd64"
|
||||
DEB_NAME="${PKG_NAME}_${VERSION}_${ARCH}.deb"
|
||||
STAGING_DIR="${ROOT_DIR}/target/deb_staging"
|
||||
|
||||
echo "=== [1/4] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
|
||||
echo "=== [1/5] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ==="
|
||||
cargo build --release
|
||||
|
||||
# Sicherstellen, dass das PNG-Icon existiert
|
||||
@@ -25,7 +25,23 @@ if [ ! -f "assets/ink-notes.png" ]; then
|
||||
python3 scripts/generate_png_icon.py
|
||||
fi
|
||||
|
||||
echo "=== [2/4] Erstelle Paketstruktur in ${STAGING_DIR} ==="
|
||||
echo "=== [2/5] Ermittle tatsächliche Abhängigkeiten (ldd & dpkg-shlibdeps) ==="
|
||||
echo "Linker-Abhängigkeiten (ldd):"
|
||||
ldd target/release/ink-notes
|
||||
|
||||
if command -v dpkg-shlibdeps >/dev/null 2>&1; then
|
||||
echo "Ermittle automatische shlibdeps via dpkg-shlibdeps..."
|
||||
TEMP_CONTROL_DIR="$(mktemp -d)"
|
||||
mkdir -p "${TEMP_CONTROL_DIR}/debian"
|
||||
echo "Source: ${PKG_NAME}" > "${TEMP_CONTROL_DIR}/debian/control"
|
||||
(
|
||||
cd "${TEMP_CONTROL_DIR}"
|
||||
dpkg-shlibdeps -O "${ROOT_DIR}/target/release/ink-notes" 2>/dev/null || true
|
||||
)
|
||||
rm -rf "${TEMP_CONTROL_DIR}"
|
||||
fi
|
||||
|
||||
echo "=== [3/5] Erstelle Paketstruktur in ${STAGING_DIR} ==="
|
||||
rm -rf "${STAGING_DIR}"
|
||||
mkdir -p "${STAGING_DIR}/DEBIAN"
|
||||
mkdir -p "${STAGING_DIR}/usr/bin"
|
||||
@@ -33,44 +49,104 @@ mkdir -p "${STAGING_DIR}/usr/share/applications"
|
||||
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps"
|
||||
mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps"
|
||||
mkdir -p "${STAGING_DIR}/usr/share/metainfo"
|
||||
mkdir -p "${STAGING_DIR}/usr/share/doc/${PKG_NAME}"
|
||||
|
||||
# 2. Dateien kopieren
|
||||
# Dateien kopieren
|
||||
cp "target/release/ink-notes" "${STAGING_DIR}/usr/bin/ink-notes"
|
||||
|
||||
# Desktop-Dateien (sowohl mit AppStream ID als auch Legacy Name)
|
||||
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/eu.pansi.ink-notes.desktop"
|
||||
cp "packaging/eu.pansi.ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
|
||||
|
||||
# Icons (eu.pansi.ink-notes & ink-notes)
|
||||
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/eu.pansi.ink-notes.svg"
|
||||
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
||||
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/eu.pansi.ink-notes.png"
|
||||
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
||||
|
||||
# AppStream Metainfo
|
||||
cp "packaging/eu.pansi.ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||
|
||||
# Debian doc: copyright
|
||||
cat <<'EOF' > "${STAGING_DIR}/usr/share/doc/ink-notes/copyright"
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: ink-notes
|
||||
Upstream-Contact: Harald <harald@pansi.eu>
|
||||
Source: https://gitea.pansi.eu/harald/ink-notes
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 Harald Pansi <harald@pansi.eu>
|
||||
License: MIT
|
||||
|
||||
License: MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
EOF
|
||||
|
||||
# Debian doc: changelog.gz
|
||||
CHANGELOG_TMP="$(mktemp)"
|
||||
cat <<EOF > "${CHANGELOG_TMP}"
|
||||
ink-notes (${VERSION}-1) stable; urgency=medium
|
||||
|
||||
* Release v${VERSION}.
|
||||
* Low-latency vector inking with two-stage pipeline.
|
||||
* Shape recognition and infinite canvas with zoom/pan.
|
||||
* Multi-notebook management and PDF export.
|
||||
|
||||
-- Harald <harald@pansi.eu> $(date -R)
|
||||
EOF
|
||||
gzip -n -9 -c "${CHANGELOG_TMP}" > "${STAGING_DIR}/usr/share/doc/ink-notes/changelog.gz"
|
||||
rm -f "${CHANGELOG_TMP}"
|
||||
|
||||
# Berechtigungen penibel setzen
|
||||
find "${STAGING_DIR}" -type d -exec chmod 755 {} +
|
||||
find "${STAGING_DIR}/usr/share" -type f -exec chmod 644 {} +
|
||||
chmod 755 "${STAGING_DIR}/DEBIAN"
|
||||
chmod 755 "${STAGING_DIR}/usr/bin/ink-notes"
|
||||
|
||||
cp "packaging/ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
|
||||
chmod 644 "${STAGING_DIR}/usr/share/applications/ink-notes.desktop"
|
||||
|
||||
cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
||||
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg"
|
||||
|
||||
cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
||||
chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png"
|
||||
|
||||
cp "packaging/ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||
chmod 644 "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml"
|
||||
|
||||
# 3. DEBIAN/control Datei generieren
|
||||
# DEBIAN/control Datei generieren
|
||||
cat <<EOF > "${STAGING_DIR}/DEBIAN/control"
|
||||
Package: ${PKG_NAME}
|
||||
Version: ${VERSION}
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: Harald <harald@pansi.eu>
|
||||
Installed-Size: $(du -ks "${STAGING_DIR}/usr" | cut -f1)
|
||||
Depends: libc6, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0
|
||||
Depends: libc6 (>= 2.38), libgcc-s1, libvulkan1, mesa-vulkan-drivers | vulkan-icd, libwayland-client0, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0, xdg-utils
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Homepage: https://farmersdog.pansi.eu/harald/ink-notes
|
||||
Homepage: https://gitea.pansi.eu/harald/ink-notes
|
||||
Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux
|
||||
ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu),
|
||||
stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen,
|
||||
Multi-Dokumenten-Verwaltung und Vektor-PDF-Export.
|
||||
EOF
|
||||
chmod 644 "${STAGING_DIR}/DEBIAN/control"
|
||||
|
||||
echo "=== [3/4] Schnüre .deb-Paket mit dpkg-deb ==="
|
||||
echo "=== [4/5] Schnüre .deb-Paket mit dpkg-deb ==="
|
||||
dpkg-deb --build --root-owner-group "${STAGING_DIR}" "${ROOT_DIR}/target/${DEB_NAME}"
|
||||
|
||||
echo "=== [4/4] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
|
||||
echo "=== [5/5] Paket erfolgreich erstellt: target/${DEB_NAME} ==="
|
||||
ls -lh "${ROOT_DIR}/target/${DEB_NAME}"
|
||||
dpkg-deb -I "${ROOT_DIR}/target/${DEB_NAME}"
|
||||
|
||||
if command -v lintian >/dev/null 2>&1; then
|
||||
echo "=== Prüfe Paket mit lintian ==="
|
||||
lintian "${ROOT_DIR}/target/${DEB_NAME}" || true
|
||||
fi
|
||||
|
||||
echo "Fertig!"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
|
||||
# Lädt .deb, .AppImage und SHA256SUMS.txt als Release-Assets hoch.
|
||||
# Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
@@ -14,13 +14,15 @@ GITEA_SERVER="${GITEA_SERVER:-https://gitea.pansi.eu}"
|
||||
REPO_OWNER="${REPO_OWNER:-harald}"
|
||||
REPO_NAME="${REPO_NAME:-ink-notes}"
|
||||
|
||||
# Token aus Argument oder Umgebungsvariablen ermitteln
|
||||
TOKEN="${1:-${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}}"
|
||||
# Token ausschließlich aus Umgebungsvariablen ermitteln (Fallback für Legacy-Aufruf mit Argument)
|
||||
TOKEN="${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}"
|
||||
if [ -z "${TOKEN}" ] && [ -n "${1:-}" ]; then
|
||||
TOKEN="$1"
|
||||
fi
|
||||
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo "FEHLER: Kein Gitea-API-Token angegeben."
|
||||
echo "Verwendung: $0 <GITEA_API_TOKEN>"
|
||||
echo "Oder setze die Umgebungsvariable INK_NOTES_GITEA_TOKEN."
|
||||
echo "Bitte setze die Umgebungsvariable GITEA_TOKEN oder INK_NOTES_GITEA_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -31,6 +33,13 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
|
||||
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
||||
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
||||
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
||||
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
|
||||
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
||||
|
||||
if [ ! -f "${TEMPLATE_FILE}" ]; then
|
||||
echo "FEHLER: Release-Template '${TEMPLATE_FILE}' nicht gefunden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sicherstellen, dass die Artefakte existieren
|
||||
if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then
|
||||
@@ -38,101 +47,197 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
|
||||
"${SCRIPT_DIR}/build-release.sh"
|
||||
fi
|
||||
|
||||
# Ohne Signatur lehnen installierte Apps das Release als Update ab
|
||||
if [ ! -f "${SIGNATURE_FILE}" ]; then
|
||||
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
||||
echo "======================================================================"
|
||||
|
||||
RELEASE_BODY=$(cat <<EOF
|
||||
## ink-notes ${TAG_NAME}
|
||||
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
||||
- **Viewport**: Stufenloser Zoom (25% - 500%) und Panning
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
|
||||
- **In-App Updater**: Automatische Versionsprüfung und Aktualisierung via Gitea Releases
|
||||
- **Vektor-PDF-Export**: Verluste freie Ausgabe für Druck und Weitergabe
|
||||
|
||||
### Installation:
|
||||
- **Zorin OS / Ubuntu / Debian (.deb)**:
|
||||
\`\`\`bash
|
||||
sudo dpkg -i ink-notes_${VERSION}_amd64.deb
|
||||
\`\`\`
|
||||
- **Portables AppImage (Alle Linux-Distributionen)**:
|
||||
\`\`\`bash
|
||||
chmod +x ink-notes_${VERSION}_x86_64.AppImage
|
||||
./ink-notes_${VERSION}_x86_64.AppImage
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
|
||||
# 1. Release via Gitea REST API erstellen (oder ID abfragen, falls bereits existent)
|
||||
CREATE_PAYLOAD=$(python3 -c "
|
||||
import json, sys
|
||||
data = {
|
||||
'tag_name': '${TAG_NAME}',
|
||||
'target_commitish': 'main',
|
||||
'name': '${RELEASE_TITLE}',
|
||||
'body': '''${RELEASE_BODY}''',
|
||||
'draft': False,
|
||||
'prerelease': False
|
||||
# 1. Payload robust per Python aus Template erzeugen (verhindert Shell-Escaping-Probleme)
|
||||
PAYLOAD_FILE="$(mktemp)"
|
||||
CLEANUP_FILES=("${PAYLOAD_FILE}")
|
||||
cleanup() {
|
||||
for f in "${CLEANUP_FILES[@]}"; do
|
||||
rm -f "${f}"
|
||||
done
|
||||
}
|
||||
print(json.dumps(data))
|
||||
")
|
||||
trap cleanup EXIT
|
||||
|
||||
RELEASE_VERSION="${VERSION}" RELEASE_TAG="${TAG_NAME}" RELEASE_TITLE="${RELEASE_TITLE}" \
|
||||
python3 -c '
|
||||
import json, os, sys
|
||||
|
||||
template_path = sys.argv[1]
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
template_content = f.read()
|
||||
|
||||
version = os.environ.get("RELEASE_VERSION", "")
|
||||
tag = os.environ.get("RELEASE_TAG", "")
|
||||
title = os.environ.get("RELEASE_TITLE", "")
|
||||
|
||||
body = template_content.replace("{{VERSION}}", version).replace("{{TAG_NAME}}", tag)
|
||||
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": "main",
|
||||
"name": title,
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
' "${TEMPLATE_FILE}" > "${PAYLOAD_FILE}"
|
||||
|
||||
API_BASE="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
||||
|
||||
echo "Erstelle Release über Gitea API..."
|
||||
HTTP_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \
|
||||
-X POST "${API_BASE}/releases" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-d "${CREATE_PAYLOAD}")
|
||||
# 2. Prüfen, ob Release bereits existiert (Idempotenz)
|
||||
echo "Prüfe bestehende Releases auf ${GITEA_SERVER}..."
|
||||
TAG_RESP_FILE="$(mktemp)"
|
||||
CLEANUP_FILES+=("${TAG_RESP_FILE}")
|
||||
|
||||
HTTP_STATUS=$(echo "${HTTP_RESPONSE}" | grep "HTTP_STATUS:" | cut -d':' -f2)
|
||||
RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '/HTTP_STATUS:/d')
|
||||
HTTP_STATUS=$(curl -s -S -o "${TAG_RESP_FILE}" -w "%{http_code}" \
|
||||
-X GET "${API_BASE}/releases/tags/${TAG_NAME}" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" || echo "000")
|
||||
|
||||
RELEASE_ID=""
|
||||
if [ "${HTTP_STATUS}" -eq 201 ]; then
|
||||
RELEASE_ID=$(echo "${RESPONSE_BODY}" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])")
|
||||
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
|
||||
else
|
||||
echo "Release existiert womöglich bereits (Status ${HTTP_STATUS}). Suche bestehendes Release..."
|
||||
EXISTING_RELEASE=$(curl -fsSL \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG_NAME}" || true)
|
||||
if [ -n "${EXISTING_RELEASE}" ]; then
|
||||
RELEASE_ID=$(echo "${EXISTING_RELEASE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "FEHLER: Konnte Release nicht erstellen oder abfragen. Serverantwort:"
|
||||
echo "${RESPONSE_BODY}"
|
||||
if [ "${HTTP_STATUS}" = "200" ]; then
|
||||
RELEASE_ID=$(python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(open(sys.argv[1]))
|
||||
print(data.get("id", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "${TAG_RESP_FILE}")
|
||||
echo "Release ${TAG_NAME} existiert bereits (ID: ${RELEASE_ID}). Aktualisiere Release-Notizen..."
|
||||
PATCH_STATUS=$(curl -s -S -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "${API_BASE}/releases/${RELEASE_ID}" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-d @"${PAYLOAD_FILE}" || echo "000")
|
||||
if [ "${PATCH_STATUS}" != "200" ]; then
|
||||
echo "Hinweis: Aktualisierung der Metadaten ergab HTTP ${PATCH_STATUS}."
|
||||
fi
|
||||
elif [ "${HTTP_STATUS}" = "404" ]; then
|
||||
echo "Release existiert noch nicht. Erstelle neues Release ${TAG_NAME}..."
|
||||
CREATE_RESP_FILE="$(mktemp)"
|
||||
CLEANUP_FILES+=("${CREATE_RESP_FILE}")
|
||||
CREATE_STATUS=$(curl -s -S -o "${CREATE_RESP_FILE}" -w "%{http_code}" \
|
||||
-X POST "${API_BASE}/releases" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-d @"${PAYLOAD_FILE}" || echo "000")
|
||||
|
||||
if [ "${CREATE_STATUS}" = "201" ]; then
|
||||
RELEASE_ID=$(python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(open(sys.argv[1]))
|
||||
print(data.get("id", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "${CREATE_RESP_FILE}")
|
||||
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
|
||||
else
|
||||
echo "FEHLER: Release konnte nicht angelegt werden (HTTP ${CREATE_STATUS})."
|
||||
cat "${CREATE_RESP_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "FEHLER: Unerwartete Serverantwort beim Abfragen des Release-Tags (HTTP ${HTTP_STATUS})."
|
||||
cat "${TAG_RESP_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Assets hochladen
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "FEHLER: Keine gültige Release-ID ermittelt."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Vorhandene Assets auflisten (um Dubletten vor Upload sauber zu löschen)
|
||||
ASSETS_LIST_FILE="$(mktemp)"
|
||||
CLEANUP_FILES+=("${ASSETS_LIST_FILE}")
|
||||
|
||||
LIST_STATUS=$(curl -s -S -o "${ASSETS_LIST_FILE}" -w "%{http_code}" \
|
||||
-X GET "${API_BASE}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" || echo "000")
|
||||
|
||||
if [ "${LIST_STATUS}" != "200" ]; then
|
||||
echo "Hinweis: Konnte bestehende Assets nicht abrufen (HTTP ${LIST_STATUS})."
|
||||
fi
|
||||
|
||||
upload_asset() {
|
||||
local file_path="$1"
|
||||
local filename="$(basename "${file_path}")"
|
||||
local filename
|
||||
filename="$(basename "${file_path}")"
|
||||
|
||||
# Asset-Namen in URL URL-kodieren
|
||||
local encoded_name
|
||||
encoded_name=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "${filename}")
|
||||
|
||||
# Prüfen, ob bereits Assets mit demselben Namen existieren (können mehrere sein)
|
||||
local existing_ids=()
|
||||
if [ -f "${ASSETS_LIST_FILE}" ] && [ -s "${ASSETS_LIST_FILE}" ]; then
|
||||
mapfile -t existing_ids < <(python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
assets = json.load(open(sys.argv[1]))
|
||||
target = sys.argv[2]
|
||||
for a in assets:
|
||||
if a.get("name") == target and a.get("id"):
|
||||
print(a.get("id"))
|
||||
except Exception:
|
||||
pass
|
||||
' "${ASSETS_LIST_FILE}" "${filename}")
|
||||
fi
|
||||
|
||||
for eid in "${existing_ids[@]}"; do
|
||||
if [ -n "${eid}" ]; then
|
||||
echo "Lösche bestehendes Asset '${filename}' (ID: ${eid}) vor Re-Upload..."
|
||||
local del_status
|
||||
del_status=$(curl -s -S -o /dev/null -w "%{http_code}" \
|
||||
-X DELETE "${API_BASE}/releases/${RELEASE_ID}/assets/${eid}" \
|
||||
-H "Authorization: token ${TOKEN}" || echo "000")
|
||||
if [ "${del_status}" != "204" ] && [ "${del_status}" != "200" ]; then
|
||||
echo "Warnung: Löschen von bestehendem Asset '${filename}' ergab HTTP ${del_status}."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Lade Asset hoch: ${filename}..."
|
||||
curl -fsSL \
|
||||
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
local upload_res_file="$(mktemp)"
|
||||
CLEANUP_FILES+=("${upload_res_file}")
|
||||
|
||||
local upload_status
|
||||
upload_status=$(curl -s -S -o "${upload_res_file}" -w "%{http_code}" \
|
||||
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${encoded_name}" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file_path}" > /dev/null
|
||||
echo "✓ ${filename} erfolgreich hochgeladen."
|
||||
--data-binary "@${file_path}" || echo "000")
|
||||
|
||||
if [ "${upload_status}" = "201" ]; then
|
||||
echo "✓ ${filename} erfolgreich hochgeladen."
|
||||
else
|
||||
echo "FEHLER: Asset-Upload für '${filename}' fehlgeschlagen (HTTP ${upload_status})."
|
||||
cat "${upload_res_file}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
upload_asset "${DEB_FILE}"
|
||||
upload_asset "${APPIMAGE_FILE}"
|
||||
upload_asset "${CHECKSUMS_FILE}"
|
||||
upload_asset "${SIGNATURE_FILE}"
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
||||
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
//! Eingebettete Bilder eines Notizbuchs (`<Notizbuch>/assets/`).
|
||||
//!
|
||||
//! Jedes Bild wird einmal unter dem SHA-256 seines Inhalts gespeichert (`<hash>.png` bzw.
|
||||
//! `<hash>.jpg`). Seiten verweisen nur auf diesen Namen; dasselbe Bild mehrfach einzufügen
|
||||
//! kostet keinen zusätzlichen Platz. Bilddateien werden nie gelöscht, damit Undo und
|
||||
//! Kopien auf anderen Seiten immer funktionieren.
|
||||
//!
|
||||
//! JPEG wird unverändert übernommen (verlustfrei, auch im PDF-Export); alle anderen Formate
|
||||
//! werden als PNG gespeichert.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::storage;
|
||||
|
||||
/// Name des Unterordners mit den Bilddateien.
|
||||
pub const ASSETS_DIR: &str = "assets";
|
||||
/// Größte Kantenlänge eines eingefügten Bildes in Pixeln; größere Bilder werden verkleinert
|
||||
/// (8000 px ≈ 300 dpi über die doppelte A4-Breite – mehr ist auf einer Seite nie sichtbar).
|
||||
pub const MAX_IMPORT_DIMENSION: u32 = 8000;
|
||||
/// Größte Kantenlänge der Bildschirmdarstellung (GPU-Textur).
|
||||
pub const MAX_DISPLAY_DIMENSION: u32 = 2048;
|
||||
/// Größte Dateigröße beim Einfügen.
|
||||
pub const MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Fehler beim Einfügen oder Laden eines Bildes.
|
||||
#[derive(Debug)]
|
||||
pub enum AssetError {
|
||||
/// Format nicht unterstützt oder Datei beschädigt
|
||||
Decode(String),
|
||||
/// Datei zu groß
|
||||
TooLarge(usize),
|
||||
/// Ungültiger Asset-Name (z. B. manipulierte Seitendatei)
|
||||
InvalidName(String),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AssetError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AssetError::Decode(msg) => write!(f, "Bild kann nicht gelesen werden: {msg}"),
|
||||
AssetError::TooLarge(bytes) => write!(
|
||||
f,
|
||||
"Bild ist zu groß ({} MB, höchstens {} MB)",
|
||||
bytes / (1024 * 1024),
|
||||
MAX_IMPORT_BYTES / (1024 * 1024)
|
||||
),
|
||||
AssetError::InvalidName(name) => write!(f, "Ungültiger Bildverweis '{name}'"),
|
||||
AssetError::Io(e) => write!(f, "Ein-/Ausgabefehler: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AssetError {}
|
||||
|
||||
impl From<std::io::Error> for AssetError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AssetError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnis eines erfolgreichen Imports.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportedImage {
|
||||
/// Dateiname im Asset-Ordner, z. B. `3f…a9.png`
|
||||
pub asset: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Dekodiertes Bild in 8-Bit-RGBA (sRGB).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RgbaImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png` oder `.jpg`. Verhindert, dass
|
||||
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
|
||||
pub fn is_valid_asset_name(name: &str) -> bool {
|
||||
let Some((hash, ext)) = name.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
hash.len() == 64
|
||||
&& hash
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
&& matches!(ext, "png" | "jpg")
|
||||
}
|
||||
|
||||
/// Bilddateien eines Notizbuchs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssetStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AssetStore {
|
||||
/// Asset-Ordner des Notizbuchs in `notebook_dir`.
|
||||
pub fn new(notebook_dir: &Path) -> Self {
|
||||
Self {
|
||||
dir: notebook_dir.join(ASSETS_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Pfad einer Bilddatei (nur für gültige Namen).
|
||||
pub fn path_of(&self, asset: &str) -> Result<PathBuf, AssetError> {
|
||||
if is_valid_asset_name(asset) {
|
||||
Ok(self.dir.join(asset))
|
||||
} else {
|
||||
Err(AssetError::InvalidName(asset.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt eine Bilddatei (Inhalt einer PNG-, JPEG-, GIF-, WebP- oder BMP-Datei).
|
||||
pub fn import_bytes(&self, bytes: &[u8]) -> Result<ImportedImage, AssetError> {
|
||||
if bytes.len() > MAX_IMPORT_BYTES {
|
||||
return Err(AssetError::TooLarge(bytes.len()));
|
||||
}
|
||||
let format = image::guess_format(bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
let decoded = image::load_from_memory_with_format(bytes, format)
|
||||
.map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
|
||||
let fits =
|
||||
decoded.width() <= MAX_IMPORT_DIMENSION && decoded.height() <= MAX_IMPORT_DIMENSION;
|
||||
if format == image::ImageFormat::Jpeg && fits {
|
||||
// JPEG unverändert übernehmen (kein erneuter Qualitätsverlust)
|
||||
return self.store(bytes, "jpg", decoded.width(), decoded.height());
|
||||
}
|
||||
let decoded = if fits {
|
||||
decoded
|
||||
} else {
|
||||
decoded.resize(
|
||||
MAX_IMPORT_DIMENSION,
|
||||
MAX_IMPORT_DIMENSION,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
};
|
||||
let png = encode_png(&decoded)?;
|
||||
self.store(&png, "png", decoded.width(), decoded.height())
|
||||
}
|
||||
|
||||
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
|
||||
pub fn import_rgba(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixels: Vec<u8>,
|
||||
) -> Result<ImportedImage, AssetError> {
|
||||
let buffer = image::RgbaImage::from_raw(width, height, pixels)
|
||||
.ok_or_else(|| AssetError::Decode("Pixeldaten passen nicht zur Bildgröße".into()))?;
|
||||
let mut decoded = image::DynamicImage::ImageRgba8(buffer);
|
||||
if width > MAX_IMPORT_DIMENSION || height > MAX_IMPORT_DIMENSION {
|
||||
decoded = decoded.resize(
|
||||
MAX_IMPORT_DIMENSION,
|
||||
MAX_IMPORT_DIMENSION,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
}
|
||||
let png = encode_png(&decoded)?;
|
||||
self.store(&png, "png", decoded.width(), decoded.height())
|
||||
}
|
||||
|
||||
/// Kopiert ein Bild aus dem Asset-Ordner eines anderen Notizbuchs (Einfügen über
|
||||
/// Notizbuchgrenzen hinweg). Ist es schon vorhanden, passiert nichts.
|
||||
pub fn copy_from(&self, other: &AssetStore, asset: &str) -> Result<(), AssetError> {
|
||||
let target = self.path_of(asset)?;
|
||||
if target.exists() || other.dir == self.dir {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = std::fs::read(other.path_of(asset)?)?;
|
||||
storage::atomic_write_file(&target, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Liest die gespeicherte Bilddatei unverändert.
|
||||
pub fn read_bytes(&self, asset: &str) -> Result<Vec<u8>, AssetError> {
|
||||
Ok(std::fs::read(self.path_of(asset)?)?)
|
||||
}
|
||||
|
||||
/// Lädt ein Bild für die Bildschirmdarstellung, verkleinert auf höchstens
|
||||
/// `max_dimension` Pixel Kantenlänge.
|
||||
pub fn load_rgba(&self, asset: &str, max_dimension: u32) -> Result<RgbaImage, AssetError> {
|
||||
let bytes = self.read_bytes(asset)?;
|
||||
let mut decoded =
|
||||
image::load_from_memory(&bytes).map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
if decoded.width() > max_dimension || decoded.height() > max_dimension {
|
||||
decoded = decoded.resize(
|
||||
max_dimension,
|
||||
max_dimension,
|
||||
image::imageops::FilterType::Triangle,
|
||||
);
|
||||
}
|
||||
let rgba = decoded.into_rgba8();
|
||||
Ok(RgbaImage {
|
||||
width: rgba.width(),
|
||||
height: rgba.height(),
|
||||
pixels: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
fn store(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
ext: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<ImportedImage, AssetError> {
|
||||
let hash = Sha256::digest(bytes);
|
||||
let asset = format!("{}.{ext}", hex(&hash));
|
||||
let path = self.path_of(&asset)?;
|
||||
if !path.exists() {
|
||||
storage::atomic_write_file(&path, bytes)?;
|
||||
}
|
||||
Ok(ImportedImage {
|
||||
asset,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_png(image: &image::DynamicImage) -> Result<Vec<u8>, AssetError> {
|
||||
let mut png = Vec::new();
|
||||
image
|
||||
.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.map_err(|e| AssetError::Decode(e.to_string()))?;
|
||||
Ok(png)
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Ist die Datei dem Namen nach ein unterstütztes Bild?
|
||||
pub fn is_supported_image_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| {
|
||||
matches!(
|
||||
e.to_ascii_lowercase().as_str(),
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Kleines Testbild als Dateiinhalt im gewünschten Format.
|
||||
pub(crate) fn sample_image_bytes(
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: image::ImageFormat,
|
||||
) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(width, height, |x, y| {
|
||||
image::Rgb([
|
||||
(x * 255 / width.max(1)) as u8,
|
||||
(y * 255 / height.max(1)) as u8,
|
||||
90,
|
||||
])
|
||||
});
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgb8(img)
|
||||
.write_to(&mut Cursor::new(&mut bytes), format)
|
||||
.unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asset_names_are_validated() {
|
||||
let hash = "a".repeat(64);
|
||||
assert!(is_valid_asset_name(&format!("{hash}.png")));
|
||||
assert!(is_valid_asset_name(&format!("{hash}.jpg")));
|
||||
assert!(!is_valid_asset_name(&format!("{hash}.exe")));
|
||||
assert!(!is_valid_asset_name("../../etc/passwd"));
|
||||
assert!(!is_valid_asset_name(&format!("../{}.png", "a".repeat(61))));
|
||||
assert!(!is_valid_asset_name(&format!("{}.png", "A".repeat(64))));
|
||||
assert!(!is_valid_asset_name(""));
|
||||
|
||||
let store = AssetStore::new(Path::new("/tmp/nb"));
|
||||
assert!(store.path_of("../manifest.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_png_jpeg_and_deduplicate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = AssetStore::new(dir.path());
|
||||
|
||||
let png = sample_image_bytes(40, 20, image::ImageFormat::Png);
|
||||
let a = store.import_bytes(&png).unwrap();
|
||||
assert_eq!((a.width, a.height), (40, 20));
|
||||
assert!(a.asset.ends_with(".png"));
|
||||
assert!(store.path_of(&a.asset).unwrap().exists());
|
||||
|
||||
// Gleiches Bild nochmals: gleicher Name, keine zweite Datei
|
||||
let b = store.import_bytes(&png).unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(std::fs::read_dir(store.dir()).unwrap().count(), 1);
|
||||
|
||||
// JPEG bleibt byte-identisch
|
||||
let jpg = sample_image_bytes(30, 30, image::ImageFormat::Jpeg);
|
||||
let c = store.import_bytes(&jpg).unwrap();
|
||||
assert!(c.asset.ends_with(".jpg"));
|
||||
assert_eq!(store.read_bytes(&c.asset).unwrap(), jpg);
|
||||
|
||||
// BMP wird als PNG gespeichert
|
||||
let bmp = sample_image_bytes(8, 4, image::ImageFormat::Bmp);
|
||||
let d = store.import_bytes(&bmp).unwrap();
|
||||
assert!(d.asset.ends_with(".png"));
|
||||
|
||||
let rgba = store.load_rgba(&a.asset, MAX_DISPLAY_DIMENSION).unwrap();
|
||||
assert_eq!((rgba.width, rgba.height), (40, 20));
|
||||
assert_eq!(rgba.pixels.len(), 40 * 20 * 4);
|
||||
// Verkleinerung für die Anzeige behält das Seitenverhältnis
|
||||
let small = store.load_rgba(&a.asset, 10).unwrap();
|
||||
assert_eq!((small.width, small.height), (10, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_rejects_garbage_and_copies_between_notebooks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = AssetStore::new(&dir.path().join("a"));
|
||||
assert!(matches!(
|
||||
store.import_bytes(b"kein Bild"),
|
||||
Err(AssetError::Decode(_))
|
||||
));
|
||||
assert!(store.import_rgba(4, 4, vec![0; 3]).is_err());
|
||||
|
||||
let imported = store.import_rgba(2, 2, vec![255; 16]).unwrap();
|
||||
let other = AssetStore::new(&dir.path().join("b"));
|
||||
assert!(other.read_bytes(&imported.asset).is_err());
|
||||
other.copy_from(&store, &imported.asset).unwrap();
|
||||
assert_eq!(
|
||||
other.read_bytes(&imported.asset).unwrap(),
|
||||
store.read_bytes(&imported.asset).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supported_image_paths() {
|
||||
assert!(is_supported_image_path(Path::new("/x/Foto.JPG")));
|
||||
assert!(is_supported_image_path(Path::new("a.webp")));
|
||||
assert!(!is_supported_image_path(Path::new("a.pdf")));
|
||||
assert!(!is_supported_image_path(Path::new("png")));
|
||||
}
|
||||
}
|
||||
+902
-83
File diff suppressed because it is too large.
Load diff
+200
-37
@@ -1,5 +1,7 @@
|
||||
//! Vektor-Generierung für DIN-A4-Seitenhintergründe (Kariert, Liniert, Punktraster, Blanko).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::canvas::InkVertex;
|
||||
use crate::document::{PageBackground, PAGE_HEIGHT, PAGE_WIDTH};
|
||||
|
||||
@@ -13,27 +15,99 @@ pub const GRID_5MM_SPACING: f32 = 50.0;
|
||||
/// Zeilenabstand für liniertes Papier (7.5 mm = 75 Einheiten).
|
||||
pub const LINED_SPACING: f32 = 75.0;
|
||||
|
||||
/// Hellgraue Farbe für Hintergrundmuster (#E0E0E0).
|
||||
pub const BACKGROUND_PATTERN_COLOR: [f32; 4] = [0.88, 0.88, 0.88, 1.0];
|
||||
/// Papierweiß für das DIN-A4-Blatt (#FFFFFF).
|
||||
pub const PAPER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
|
||||
/// Schreibtisch-Hintergrundfarbe (Letterbox-Bereich, #28282D).
|
||||
pub const DESK_COLOR: wgpu::Color = wgpu::Color {
|
||||
r: 0.16,
|
||||
g: 0.16,
|
||||
b: 0.18,
|
||||
a: 1.0,
|
||||
};
|
||||
/// Papierfarbe der Anzeige. Betrifft nur die Darstellung am Bildschirm; gespeicherte
|
||||
/// Strichfarben und der PDF-Export bleiben unverändert (weißes Papier).
|
||||
///
|
||||
/// Alle Farben sind linear (die Oberfläche ist sRGB, die Umrechnung macht die GPU).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PaperTheme {
|
||||
#[default]
|
||||
White,
|
||||
/// Cremefarben (augenschonend, wie Recyclingpapier)
|
||||
Cream,
|
||||
/// Dunkelgrau (Nachtmodus)
|
||||
DarkGray,
|
||||
/// Schwarz (OLED)
|
||||
Black,
|
||||
}
|
||||
|
||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das weiße DIN-A4-Papierblatt.
|
||||
pub fn generate_paper_quad() -> Vec<InkVertex> {
|
||||
impl PaperTheme {
|
||||
pub const ALL: [PaperTheme; 4] = [
|
||||
PaperTheme::White,
|
||||
PaperTheme::Cream,
|
||||
PaperTheme::DarkGray,
|
||||
PaperTheme::Black,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PaperTheme::White => "Weiß",
|
||||
PaperTheme::Cream => "Creme",
|
||||
PaperTheme::DarkGray => "Dunkelgrau",
|
||||
PaperTheme::Black => "Schwarz",
|
||||
}
|
||||
}
|
||||
|
||||
/// Dunkles Papier: dunkle Tinte wird bei Bedarf aufgehellt.
|
||||
pub fn is_dark(self) -> bool {
|
||||
matches!(self, PaperTheme::DarkGray | PaperTheme::Black)
|
||||
}
|
||||
|
||||
/// Farbe des Blatts.
|
||||
pub fn paper_color(self) -> [f32; 4] {
|
||||
match self {
|
||||
PaperTheme::White => [1.0, 1.0, 1.0, 1.0],
|
||||
// sRGB #FBF5E6
|
||||
PaperTheme::Cream => [0.965, 0.913, 0.791, 1.0],
|
||||
// sRGB #2B2B2E
|
||||
PaperTheme::DarkGray => [0.024, 0.024, 0.027, 1.0],
|
||||
PaperTheme::Black => [0.0, 0.0, 0.0, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Farbe von Karos, Linien und Punkten.
|
||||
pub fn pattern_color(self) -> [f32; 4] {
|
||||
match self {
|
||||
PaperTheme::White => [0.88, 0.88, 0.88, 1.0],
|
||||
PaperTheme::Cream => [0.78, 0.72, 0.60, 1.0],
|
||||
// sRGB #48484D bzw. #38383C
|
||||
PaperTheme::DarkGray => [0.064, 0.064, 0.074, 1.0],
|
||||
PaperTheme::Black => [0.040, 0.040, 0.045, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Schreibtisch-Hintergrund um das Blatt; bei dunklem Papier noch dunkler, damit sich
|
||||
/// das Blatt abhebt.
|
||||
pub fn desk_color(self) -> wgpu::Color {
|
||||
let (r, g, b) = match self {
|
||||
PaperTheme::White | PaperTheme::Cream => (0.16, 0.16, 0.18),
|
||||
PaperTheme::DarkGray => (0.004, 0.004, 0.005),
|
||||
PaperTheme::Black => (0.03, 0.03, 0.035),
|
||||
};
|
||||
wgpu::Color { r, g, b, a: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hellt dunkle Tinte für dunkles Papier auf: die HSL-Helligkeit L wird zu 1 − L gespiegelt,
|
||||
/// Farbton und Sättigung bleiben erhalten. Farben mit L ≥ 0.5 (Rot, Blau, Textmarker)
|
||||
/// bleiben unverändert. Muss mit `fs_ink` in `shader.wgsl` übereinstimmen.
|
||||
pub fn adapt_ink_color(color: [f32; 4]) -> [f32; 4] {
|
||||
let max = color[0].max(color[1]).max(color[2]);
|
||||
let min = color[0].min(color[1]).min(color[2]);
|
||||
let lift = (1.0 - max - min).max(0.0);
|
||||
[color[0] + lift, color[1] + lift, color[2] + lift, color[3]]
|
||||
}
|
||||
|
||||
/// Erzeugt die 6 Vertizes (2 Dreiecke) für das DIN-A4-Papierblatt.
|
||||
pub fn generate_paper_quad(color: [f32; 4]) -> Vec<InkVertex> {
|
||||
vec![
|
||||
InkVertex::new(0.0, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, PAPER_COLOR),
|
||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, PAPER_COLOR),
|
||||
InkVertex::new(0.0, 0.0, color),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||
InkVertex::new(0.0, PAGE_HEIGHT, color),
|
||||
InkVertex::new(PAGE_WIDTH, 0.0, color),
|
||||
InkVertex::new(PAGE_WIDTH, PAGE_HEIGHT, color),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -41,21 +115,22 @@ pub fn generate_paper_quad() -> Vec<InkVertex> {
|
||||
#[inline]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<InkVertex>,
|
||||
color: [f32; 4],
|
||||
x_min: f32,
|
||||
y_min: f32,
|
||||
x_max: f32,
|
||||
y_max: f32,
|
||||
) {
|
||||
vertices.push(InkVertex::new(x_min, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_min, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_max, y_max, BACKGROUND_PATTERN_COLOR));
|
||||
vertices.push(InkVertex::new(x_min, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_min, y_max, color));
|
||||
vertices.push(InkVertex::new(x_max, y_min, color));
|
||||
vertices.push(InkVertex::new(x_max, y_max, color));
|
||||
}
|
||||
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund.
|
||||
pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
/// Generiert das statische Vektor-Mesh für den gewählten Seitenhintergrund in `color`.
|
||||
pub fn generate_background_mesh(background: PageBackground, color: [f32; 4]) -> Vec<InkVertex> {
|
||||
match background {
|
||||
PageBackground::Blank => Vec::new(),
|
||||
PageBackground::Grid5mm => {
|
||||
@@ -65,14 +140,28 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
// Vertikale Linien alle 5 mm
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < PAGE_WIDTH {
|
||||
push_quad(&mut vertices, x - half_t, 0.0, x + half_t, PAGE_HEIGHT);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_t,
|
||||
0.0,
|
||||
x + half_t,
|
||||
PAGE_HEIGHT,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
// Horizontale Linien alle 5 mm
|
||||
let mut y = GRID_5MM_SPACING;
|
||||
while y < PAGE_HEIGHT {
|
||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
PAGE_WIDTH,
|
||||
y + half_t,
|
||||
);
|
||||
y += GRID_5MM_SPACING;
|
||||
}
|
||||
|
||||
@@ -88,7 +177,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
|
||||
let mut y = margin_top;
|
||||
while y <= PAGE_HEIGHT - margin_bottom {
|
||||
push_quad(&mut vertices, 0.0, y - half_t, PAGE_WIDTH, y + half_t);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
0.0,
|
||||
y - half_t,
|
||||
PAGE_WIDTH,
|
||||
y + half_t,
|
||||
);
|
||||
y += LINED_SPACING;
|
||||
}
|
||||
|
||||
@@ -102,7 +198,14 @@ pub fn generate_background_mesh(background: PageBackground) -> Vec<InkVertex> {
|
||||
while y < PAGE_HEIGHT {
|
||||
let mut x = GRID_5MM_SPACING;
|
||||
while x < PAGE_WIDTH {
|
||||
push_quad(&mut vertices, x - half_d, y - half_d, x + half_d, y + half_d);
|
||||
push_quad(
|
||||
&mut vertices,
|
||||
color,
|
||||
x - half_d,
|
||||
y - half_d,
|
||||
x + half_d,
|
||||
y + half_d,
|
||||
);
|
||||
x += GRID_5MM_SPACING;
|
||||
}
|
||||
y += GRID_5MM_SPACING;
|
||||
@@ -119,27 +222,87 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_paper_quad_generation() {
|
||||
let quad = generate_paper_quad();
|
||||
let quad = generate_paper_quad(PaperTheme::White.paper_color());
|
||||
assert_eq!(quad.len(), 6);
|
||||
assert_eq!(quad[0].position, [0.0, 0.0]);
|
||||
assert_eq!(quad[5].position, [PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
}
|
||||
|
||||
const PATTERN: [f32; 4] = [0.5, 0.5, 0.5, 1.0];
|
||||
|
||||
#[test]
|
||||
fn test_background_meshes() {
|
||||
let blank = generate_background_mesh(PageBackground::Blank);
|
||||
let blank = generate_background_mesh(PageBackground::Blank, PATTERN);
|
||||
assert!(blank.is_empty());
|
||||
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm);
|
||||
let grid = generate_background_mesh(PageBackground::Grid5mm, PATTERN);
|
||||
assert!(!grid.is_empty());
|
||||
assert_eq!(grid.len() % 6, 0, "Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)");
|
||||
assert_eq!(
|
||||
grid.len() % 6,
|
||||
0,
|
||||
"Muss ein Vielfaches von 6 Vertizes sein (Dreiecke)"
|
||||
);
|
||||
|
||||
let lined = generate_background_mesh(PageBackground::Lined);
|
||||
let lined = generate_background_mesh(PageBackground::Lined, PATTERN);
|
||||
assert!(!lined.is_empty());
|
||||
assert_eq!(lined.len() % 6, 0);
|
||||
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid);
|
||||
let dots = generate_background_mesh(PageBackground::DotGrid, PATTERN);
|
||||
assert!(!dots.is_empty());
|
||||
assert_eq!(dots.len() % 6, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_mesh_uses_theme_color() {
|
||||
let dark = PaperTheme::Black.pattern_color();
|
||||
let mesh = generate_background_mesh(PageBackground::Lined, dark);
|
||||
assert!(mesh.iter().all(|v| v.color == dark));
|
||||
let quad = generate_paper_quad(PaperTheme::Cream.paper_color());
|
||||
assert!(quad
|
||||
.iter()
|
||||
.all(|v| v.color == PaperTheme::Cream.paper_color()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapt_ink_color() {
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
// Schwarz wird Weiß, Deckkraft bleibt
|
||||
assert!(close(
|
||||
adapt_ink_color([0.0, 0.0, 0.0, 0.7]),
|
||||
[1.0, 1.0, 1.0, 0.7]
|
||||
));
|
||||
// Notiz-Schwarz wird hell
|
||||
let ink = adapt_ink_color([0.12, 0.12, 0.14, 1.0]);
|
||||
assert!(ink[0] > 0.85 && ink[2] > 0.85);
|
||||
// Dunkelblau wird Hellblau: Farbton bleibt (Blau bleibt der stärkste Kanal)
|
||||
let blue = adapt_ink_color([0.0, 0.0, 0.5, 1.0]);
|
||||
assert!(close(blue, [0.5, 0.5, 1.0, 1.0]));
|
||||
// Helle und gesättigte Farben bleiben unverändert (Rot, Universitäts-Blau, Textmarker)
|
||||
for c in [
|
||||
[0.85, 0.20, 0.20, 1.0],
|
||||
[0.15, 0.35, 0.85, 1.0],
|
||||
[1.0, 0.95, 0.1, 0.35],
|
||||
[1.0, 1.0, 1.0, 1.0],
|
||||
] {
|
||||
assert!(close(adapt_ink_color(c), c), "{c:?} wurde verändert");
|
||||
}
|
||||
// Ergebnis ist nie heller als Weiß
|
||||
for c in [[0.0, 0.3, 0.1, 1.0], [0.45, 0.45, 0.48, 1.0]] {
|
||||
assert!(adapt_ink_color(c)[..3].iter().all(|&x| x <= 1.0 + 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paper_themes_contrast() {
|
||||
for theme in PaperTheme::ALL {
|
||||
let paper = theme.paper_color()[0];
|
||||
let pattern = theme.pattern_color()[0];
|
||||
assert!(
|
||||
(paper - pattern).abs() > 0.03,
|
||||
"{theme:?}: Muster hebt sich nicht vom Papier ab"
|
||||
);
|
||||
// Helles Papier ↔ dunkles Papier
|
||||
assert_eq!(theme.is_dark(), paper < 0.5, "{theme:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Crash-Simulationstesthelfer für ink-notes.
|
||||
//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge
|
||||
//! und SIGKILL (kill -9) Robustheit zu verifizieren.
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
use std::time::Duration;
|
||||
|
||||
use glam::Vec2;
|
||||
use ink_notes::document::{Notebook, PageBackground, StylusSample};
|
||||
use ink_notes::spline::{finalize_stroke_with_config, SplineConfig};
|
||||
use ink_notes::storage::{
|
||||
cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page,
|
||||
NotebookManifest, PageData,
|
||||
};
|
||||
|
||||
fn run_init(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nb = Notebook::new();
|
||||
let id = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("crash_test");
|
||||
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
|
||||
m.sync_from_notebook(&nb);
|
||||
save_notebook(dir, &nb, &mut m)?;
|
||||
println!("[init] Initialized clean notebook in {:?}", dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_worker(dir: &Path) {
|
||||
let (mut notebook, mut manifest) = match load_notebook(dir) {
|
||||
Ok((nb, m)) => (nb, m),
|
||||
Err(_) => {
|
||||
let nb = Notebook::new();
|
||||
let id = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("crash_test");
|
||||
let mut m = NotebookManifest::new(id, "Crash Test Notebook");
|
||||
m.sync_from_notebook(&nb);
|
||||
let _ = save_notebook(dir, &nb, &mut m);
|
||||
(nb, m)
|
||||
}
|
||||
};
|
||||
|
||||
let spline_cfg = SplineConfig::default();
|
||||
let mut stroke_counter = manifest.next_stroke_id;
|
||||
|
||||
println!("[worker] Started writing loop in {:?}", dir);
|
||||
|
||||
loop {
|
||||
// Erzeuge neuen Strich mit einigen Samples
|
||||
let base_x = (stroke_counter % 500) as f32;
|
||||
let samples = vec![
|
||||
StylusSample {
|
||||
position: Vec2::new(base_x, 100.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: Vec2::new(base_x + 10.0, 150.0),
|
||||
pressure: 0.7,
|
||||
timestamp: 0.01,
|
||||
},
|
||||
StylusSample {
|
||||
position: Vec2::new(base_x + 20.0, 200.0),
|
||||
pressure: 0.6,
|
||||
timestamp: 0.02,
|
||||
},
|
||||
];
|
||||
|
||||
let stroke = finalize_stroke_with_config(
|
||||
stroke_counter,
|
||||
&samples,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
&spline_cfg,
|
||||
);
|
||||
stroke_counter += 1;
|
||||
manifest.next_stroke_id = stroke_counter;
|
||||
|
||||
let page = notebook.current_page_mut();
|
||||
page.add_stroke(stroke);
|
||||
|
||||
let page_data = PageData::from(&*page);
|
||||
|
||||
// 1. Schreibe Seite atomar
|
||||
if let Err(e) = save_page(dir, &page_data) {
|
||||
eprintln!("[worker] Error saving page: {:?}", e);
|
||||
}
|
||||
|
||||
// 2. Aktualisiere Manifest atomar
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
if let Err(e) = save_manifest(dir, &manifest) {
|
||||
eprintln!("[worker] Error saving manifest: {:?}", e);
|
||||
}
|
||||
|
||||
// 3. Gelegentlich Gesamtsave (wie Autosave-Flush)
|
||||
if stroke_counter % 5 == 0 {
|
||||
if let Err(e) = save_notebook(dir, ¬ebook, &mut manifest) {
|
||||
eprintln!("[worker] Error saving notebook: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird
|
||||
if stroke_counter % 25 == 0 {
|
||||
let page_idx = notebook.add_page(PageBackground::DotGrid);
|
||||
notebook.current_page_index = page_idx;
|
||||
let new_page_data = PageData::from(notebook.current_page());
|
||||
if let Err(e) = save_page(dir, &new_page_data) {
|
||||
eprintln!("[worker] Error saving new page: {:?}", e);
|
||||
}
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
if let Err(e) = save_manifest(dir, &manifest) {
|
||||
eprintln!("[worker] Error saving manifest: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_verify(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("[verify] Checking notebook in {:?}", dir);
|
||||
|
||||
// 1. Bereinige eventuelle verwaiste .tmp-Dateien
|
||||
let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0));
|
||||
|
||||
// Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet)
|
||||
let manifest_path = dir.join("manifest.json");
|
||||
let manifest_bak = dir.join("manifest.json.bak");
|
||||
if !manifest_path.exists() && !manifest_bak.exists() {
|
||||
println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Notizbuch laden
|
||||
let (_notebook, manifest) = match load_notebook(dir) {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
eprintln!("[verify] FAILED to load notebook: {:?}", e);
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}",
|
||||
manifest.title,
|
||||
manifest.pages.len(),
|
||||
manifest.next_stroke_id
|
||||
);
|
||||
|
||||
// 3. Alle im Manifest registrierten Seiten laden und prüfen
|
||||
for page_meta in &manifest.pages {
|
||||
match load_page(dir, page_meta.id) {
|
||||
Ok(page_data) => {
|
||||
let page = page_data.to_page();
|
||||
println!(
|
||||
"[verify] Page {} verified: {} strokes, background={:?}",
|
||||
page_meta.id,
|
||||
page.strokes.len(),
|
||||
page.background
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[verify] FAILED to load page {} referenced by manifest: {:?}",
|
||||
page_meta.id, e
|
||||
);
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Prüfen ob Quarantäne-Dateien existieren
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if name.contains(".corrupt-") {
|
||||
println!("[verify] Notice: Found quarantined file: {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("[verify] SUCCESS: All pages and manifest are fully consistent!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: crash_sim <init|worker|verify> <notebook_dir>");
|
||||
process::exit(2);
|
||||
}
|
||||
|
||||
let command = &args[1];
|
||||
let notebook_dir = Path::new(&args[2]);
|
||||
|
||||
match command.as_str() {
|
||||
"init" => match run_init(notebook_dir) {
|
||||
Ok(_) => process::exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("[init] Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
},
|
||||
"worker" => {
|
||||
run_worker(notebook_dir);
|
||||
}
|
||||
"verify" => match run_verify(notebook_dir) {
|
||||
Ok(_) => process::exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("[verify] Error: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
},
|
||||
other => {
|
||||
eprintln!("Unknown command: {}", other);
|
||||
process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
+267
-65
@@ -11,9 +11,7 @@ use std::sync::Arc;
|
||||
use winit::dpi::PhysicalSize;
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::background::{
|
||||
generate_background_mesh, generate_paper_quad, DESK_COLOR,
|
||||
};
|
||||
use crate::background::{generate_background_mesh, generate_paper_quad, PaperTheme};
|
||||
use crate::document::{Page, PageBackground, PageViewport, Stroke, StylusSample};
|
||||
use crate::filter::OneEuroFilter;
|
||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||
@@ -60,6 +58,8 @@ impl InkVertex {
|
||||
pub struct CameraUniforms {
|
||||
pub transform_scale: [f32; 2],
|
||||
pub transform_offset: [f32; 2],
|
||||
/// x: 1.0 = dunkle Tinte aufhellen (Shader `fs_ink`), y–w: ungenutzt (Ausrichtung)
|
||||
pub ink_params: [f32; 4],
|
||||
}
|
||||
|
||||
/// Transformiert einen Pixel-Koordinatenpunkt in den Normalized Device Coordinate (NDC) Raum [-1.0, 1.0].
|
||||
@@ -89,13 +89,43 @@ pub fn spline_vertices_to_ndc(
|
||||
}
|
||||
|
||||
/// Verwaltet den GPU-Zustand, Surface, Pipelines und Buffer für das Inking.
|
||||
/// Meldung, wenn kein Grafikadapter das Fenster darstellen kann.
|
||||
pub const NO_USABLE_GPU_MESSAGE: &str = "Keine nutzbare Grafikschnittstelle gefunden.\n\
|
||||
Häufigste Ursache: Der Benutzer darf nicht auf die Grafikkarte zugreifen (/dev/dri).\n\
|
||||
Prüfen mit `ls -l /dev/dri` und `groups`; ggf. `sudo usermod -aG render,video $USER`\n\
|
||||
und neu anmelden. Alternativ einen Software-Renderer installieren (Paket mesa-vulkan-drivers)\n\
|
||||
oder die Schnittstelle festlegen, z. B. `WGPU_BACKEND=vulkan ink-notes`.";
|
||||
|
||||
/// Wählt den Darstellungsmodus: Mailbox (geringste Latenz), sonst Fifo, sonst den ersten
|
||||
/// angebotenen. `None`, wenn keiner angeboten wird. `INK_FORCE_FIFO` erzwingt Fifo.
|
||||
pub fn choose_present_mode(available: &[wgpu::PresentMode]) -> Option<wgpu::PresentMode> {
|
||||
let force_fifo = std::env::var("INK_FORCE_FIFO").is_ok();
|
||||
let preferred: &[wgpu::PresentMode] = if force_fifo {
|
||||
&[wgpu::PresentMode::Fifo]
|
||||
} else {
|
||||
&[wgpu::PresentMode::Mailbox, wgpu::PresentMode::Fifo]
|
||||
};
|
||||
preferred
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|m| available.contains(m))
|
||||
.or_else(|| available.first().copied())
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
pub surface: wgpu::Surface<'static>,
|
||||
/// Verwendeter Grafikadapter (für erneute Abfrage der Surface-Fähigkeiten bei Größenänderung)
|
||||
adapter: wgpu::Adapter,
|
||||
/// Meldung „keine Darstellungsmodi“ wurde bereits ausgegeben
|
||||
presentation_warned: bool,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
pub size: PhysicalSize<u32>,
|
||||
pub viewport: PageViewport,
|
||||
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||
ui_inset_left: f32,
|
||||
ui_inset_right: f32,
|
||||
pub camera: crate::viewport::ViewportState,
|
||||
|
||||
// Pipelines
|
||||
@@ -106,6 +136,7 @@ pub struct CanvasState {
|
||||
pub camera_buffer: wgpu::Buffer,
|
||||
pub camera_bind_group: wgpu::BindGroup,
|
||||
pub current_ink_color: [f32; 4],
|
||||
pub current_stroke_width_mm: f32,
|
||||
|
||||
// Statische Hintergrund-Geometrie
|
||||
pub paper_buffer: wgpu::Buffer,
|
||||
@@ -113,6 +144,10 @@ pub struct CanvasState {
|
||||
pub background_vertex_capacity: usize,
|
||||
pub background_vertex_count: u32,
|
||||
pub current_background: PageBackground,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Tinte auf dunklem Papier aufhellen
|
||||
pub adapt_ink: bool,
|
||||
|
||||
// Pipeline-Trennung: Live-Strich (aktiver Stroke)
|
||||
pub live_vertex_buffer: wgpu::Buffer,
|
||||
@@ -130,6 +165,9 @@ pub struct CanvasState {
|
||||
pub committed_vertex_count: u32,
|
||||
pub committed_strokes: Vec<Range<u32>>,
|
||||
|
||||
// Eingefügte Bilder der aktuellen Seite
|
||||
image_layer: crate::image_layer::ImageLayer,
|
||||
|
||||
// egui UI-Renderer
|
||||
pub egui_renderer: egui_wgpu::Renderer,
|
||||
}
|
||||
@@ -143,25 +181,50 @@ impl CanvasState {
|
||||
/// Initialisiert den CanvasState für das übergebene Winit-Fenster.
|
||||
pub async fn new(window: Arc<Window>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let size = window.inner_size();
|
||||
let viewport = PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
||||
let viewport =
|
||||
PageViewport::calculate(size.width as f32, size.height as f32, Self::VIEWPORT_MARGIN);
|
||||
|
||||
// 1. WGPU Instance & Surface
|
||||
// 1. WGPU Instance & Surface (Grafikschnittstelle per WGPU_BACKEND wählbar, z. B. "vulkan")
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::all(),
|
||||
backends: wgpu::util::backend_bits_from_env().unwrap_or(wgpu::Backends::all()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
|
||||
// 2. Adapter auswählen (hohe Performance bevorzugt)
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter: false,
|
||||
})
|
||||
.await
|
||||
.ok_or("Kein passender GPU-Adapter gefunden")?;
|
||||
// 2. Adapter auswählen: zuerst die GPU, dann der Software-Renderer (z. B. lavapipe).
|
||||
// Ein Adapter taugt nur, wenn er für dieses Fenster Formate und Darstellungsmodi
|
||||
// anbietet – fehlt z. B. der Zugriff auf /dev/dri, ist die Liste leer.
|
||||
let mut chosen = None;
|
||||
for force_fallback_adapter in [false, true] {
|
||||
let Some(adapter) = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let info = adapter.get_info();
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
match choose_present_mode(&caps.present_modes) {
|
||||
Some(mode) if !caps.formats.is_empty() && !caps.alpha_modes.is_empty() => {
|
||||
eprintln!(
|
||||
"[Grafik] Adapter: {} ({:?}), Darstellungsmodus {:?}",
|
||||
info.name, info.backend, mode
|
||||
);
|
||||
chosen = Some((adapter, caps, mode));
|
||||
break;
|
||||
}
|
||||
_ => eprintln!(
|
||||
"[Grafik] Adapter {} ({:?}) bietet für das Fenster keine Darstellung an, versuche den nächsten.",
|
||||
info.name, info.backend
|
||||
),
|
||||
}
|
||||
}
|
||||
let (adapter, surface_caps, present_mode) = chosen.ok_or(NO_USABLE_GPU_MESSAGE)?;
|
||||
|
||||
// 3. Device & Queue erstellen
|
||||
let (device, queue) = adapter
|
||||
@@ -175,8 +238,7 @@ impl CanvasState {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Zero-Latency-Pufferung: Mailbox mit Fallback Fifo & Frame-Latency 1
|
||||
let surface_caps = surface.get_capabilities(&adapter);
|
||||
// 4. Zero-Latency-Pufferung (Mailbox, sonst Fifo) & Frame-Latency 1
|
||||
let surface_format = surface_caps
|
||||
.formats
|
||||
.iter()
|
||||
@@ -184,17 +246,6 @@ impl CanvasState {
|
||||
.find(|f| f.is_srgb())
|
||||
.unwrap_or(surface_caps.formats[0]);
|
||||
|
||||
let present_mode = if std::env::var("INK_FORCE_FIFO").is_ok() {
|
||||
eprintln!("Erzwinge PresentMode::Fifo (via INK_FORCE_FIFO)");
|
||||
wgpu::PresentMode::Fifo
|
||||
} else if surface_caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
|
||||
eprintln!("Verwende Zero-Latency PresentMode::Mailbox");
|
||||
wgpu::PresentMode::Mailbox
|
||||
} else {
|
||||
eprintln!("Mailbox nicht verfügbar, Fallback auf PresentMode::Fifo");
|
||||
wgpu::PresentMode::Fifo
|
||||
};
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format: surface_format,
|
||||
@@ -233,6 +284,7 @@ impl CanvasState {
|
||||
let camera_uniforms = CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [0.0; 4],
|
||||
};
|
||||
let camera_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Camera Uniform Buffer"),
|
||||
@@ -269,7 +321,7 @@ impl CanvasState {
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs_main",
|
||||
entry_point: "fs_ink",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
@@ -323,7 +375,8 @@ impl CanvasState {
|
||||
});
|
||||
|
||||
// 6. Statische Buffer
|
||||
let paper_quad = generate_paper_quad();
|
||||
let paper_theme = PaperTheme::default();
|
||||
let paper_quad = generate_paper_quad(paper_theme.paper_color());
|
||||
let paper_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Paper Quad Buffer"),
|
||||
size: (paper_quad.len() * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -333,7 +386,7 @@ impl CanvasState {
|
||||
queue.write_buffer(&paper_buffer, 0, bytemuck::cast_slice(&paper_quad));
|
||||
|
||||
let current_background = PageBackground::Grid5mm;
|
||||
let bg_mesh = generate_background_mesh(current_background);
|
||||
let bg_mesh = generate_background_mesh(current_background, paper_theme.pattern_color());
|
||||
let background_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Background Mesh Buffer"),
|
||||
size: (Self::INITIAL_BACKGROUND_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -357,31 +410,40 @@ impl CanvasState {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
// 8. egui Renderer für das schwebende UI initialisieren
|
||||
let egui_renderer = egui_wgpu::Renderer::new(
|
||||
let image_layer = crate::image_layer::ImageLayer::new(
|
||||
&device,
|
||||
&shader,
|
||||
&bind_group_layout,
|
||||
config.format,
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
// 8. egui Renderer für das schwebende UI initialisieren
|
||||
let egui_renderer = egui_wgpu::Renderer::new(&device, config.format, None, 1);
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
adapter,
|
||||
presentation_warned: false,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
size,
|
||||
viewport,
|
||||
ui_inset_left: 0.0,
|
||||
ui_inset_right: 0.0,
|
||||
strip_pipeline,
|
||||
triangle_pipeline,
|
||||
camera_buffer,
|
||||
camera_bind_group,
|
||||
current_ink_color: default_ink_color,
|
||||
current_stroke_width_mm: 0.5,
|
||||
paper_buffer,
|
||||
background_buffer,
|
||||
background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY,
|
||||
background_vertex_count: bg_mesh.len() as u32,
|
||||
current_background,
|
||||
paper_theme,
|
||||
adapt_ink: false,
|
||||
live_vertex_buffer,
|
||||
live_vertex_capacity: Self::INITIAL_LIVE_CAPACITY,
|
||||
live_vertex_count: 0,
|
||||
@@ -398,6 +460,7 @@ impl CanvasState {
|
||||
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
||||
committed_vertex_count: 0,
|
||||
committed_strokes: Vec::with_capacity(128),
|
||||
image_layer,
|
||||
egui_renderer,
|
||||
camera: crate::viewport::ViewportState::new(),
|
||||
})
|
||||
@@ -409,8 +472,45 @@ impl CanvasState {
|
||||
let camera_uniforms = CameraUniforms {
|
||||
transform_scale: scale,
|
||||
transform_offset: offset,
|
||||
ink_params: [
|
||||
if self.adapt_ink && self.paper_theme.is_dark() {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[camera_uniforms]));
|
||||
self.queue.write_buffer(
|
||||
&self.camera_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[camera_uniforms]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||
return false;
|
||||
}
|
||||
self.ui_inset_left = left;
|
||||
self.ui_inset_right = right;
|
||||
self.recalculate_viewport();
|
||||
true
|
||||
}
|
||||
|
||||
fn recalculate_viewport(&mut self) {
|
||||
self.viewport = PageViewport::calculate_with_insets(
|
||||
self.size.width as f32,
|
||||
self.size.height as f32,
|
||||
Self::VIEWPORT_MARGIN,
|
||||
self.ui_inset_left,
|
||||
self.ui_inset_right,
|
||||
);
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||
@@ -419,27 +519,39 @@ impl CanvasState {
|
||||
self.size = new_size;
|
||||
self.config.width = new_size.width;
|
||||
self.config.height = new_size.height;
|
||||
// Fähigkeiten neu abfragen: Ein nicht (mehr) angebotener Modus führt in wgpu zum Absturz
|
||||
let caps = self.surface.get_capabilities(&self.adapter);
|
||||
if !caps.present_modes.contains(&self.config.present_mode) {
|
||||
match choose_present_mode(&caps.present_modes) {
|
||||
Some(mode) => self.config.present_mode = mode,
|
||||
None => {
|
||||
if !self.presentation_warned {
|
||||
eprintln!(
|
||||
"[Grafik] Fenster kann derzeit nicht dargestellt werden (keine Darstellungsmodi). {}",
|
||||
NO_USABLE_GPU_MESSAGE
|
||||
);
|
||||
self.presentation_warned = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
|
||||
self.viewport = PageViewport::calculate(
|
||||
new_size.width as f32,
|
||||
new_size.height as f32,
|
||||
Self::VIEWPORT_MARGIN,
|
||||
);
|
||||
|
||||
self.update_uniforms();
|
||||
self.recalculate_viewport();
|
||||
}
|
||||
}
|
||||
|
||||
/// Verschiebt den Bildausschnitt um Bildschirm-Pixel delta.
|
||||
pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
|
||||
self.camera.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
|
||||
self.camera
|
||||
.pan_by_screen_delta(delta_x, delta_y, &self.viewport);
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Zoomt zentriert auf eine Cursor-Bildschirmposition.
|
||||
pub fn zoom_at(&mut self, cursor_x: f32, cursor_y: f32, factor: f32) {
|
||||
self.camera.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
|
||||
self.camera
|
||||
.zoom_at_cursor([cursor_x, cursor_y], factor, &self.viewport);
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
@@ -451,12 +563,14 @@ impl CanvasState {
|
||||
|
||||
/// Transformiert Fenster-Pixel in Seitenkoordinaten.
|
||||
pub fn screen_to_page(&self, screen_x: f32, screen_y: f32) -> Option<glam::Vec2> {
|
||||
self.camera.screen_to_page(screen_x, screen_y, &self.viewport)
|
||||
self.camera
|
||||
.screen_to_page(screen_x, screen_y, &self.viewport)
|
||||
}
|
||||
|
||||
/// Transformiert Fenster-Pixel in Seitenkoordinaten und klemmt auf die Seite.
|
||||
pub fn screen_to_page_clamped(&self, screen_x: f32, screen_y: f32) -> glam::Vec2 {
|
||||
self.camera.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
|
||||
self.camera
|
||||
.screen_to_page_clamped(screen_x, screen_y, &self.viewport)
|
||||
}
|
||||
|
||||
/// Transformiert einen Punkt aus DIN-A4-Seitenkoordinaten in Fenster-Bildschirmkoordinaten.
|
||||
@@ -471,7 +585,8 @@ impl CanvasState {
|
||||
self.live_vertex_count = self.live_vertices.len() as u32;
|
||||
|
||||
if self.live_vertices.len() > self.live_vertex_capacity {
|
||||
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
||||
self.live_vertex_capacity =
|
||||
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
||||
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
||||
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -496,10 +611,24 @@ impl CanvasState {
|
||||
|
||||
/// Konfiguriert die Strichbreite in Millimetern.
|
||||
pub fn set_stroke_width_mm(&mut self, width_mm: f32) {
|
||||
// 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0.
|
||||
let base_radius = (width_mm * 5.0).max(0.25);
|
||||
self.spline_config.min_radius = (base_radius * 0.5).max(0.15);
|
||||
self.spline_config.max_radius = (base_radius * 1.5).max(0.35);
|
||||
self.current_stroke_width_mm = width_mm;
|
||||
self.spline_config =
|
||||
SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM);
|
||||
}
|
||||
|
||||
/// Setzt die Papierfarbe und ob dunkle Tinte auf dunklem Papier aufgehellt wird.
|
||||
pub fn set_paper_theme(&mut self, theme: PaperTheme, adapt_ink: bool) {
|
||||
self.adapt_ink = adapt_ink;
|
||||
if self.paper_theme != theme {
|
||||
self.paper_theme = theme;
|
||||
self.queue.write_buffer(
|
||||
&self.paper_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&generate_paper_quad(theme.paper_color())),
|
||||
);
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
self.update_uniforms();
|
||||
}
|
||||
|
||||
/// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer.
|
||||
@@ -508,7 +637,13 @@ impl CanvasState {
|
||||
return;
|
||||
}
|
||||
self.current_background = background;
|
||||
let mesh = generate_background_mesh(background);
|
||||
self.upload_background_mesh();
|
||||
}
|
||||
|
||||
/// Erzeugt das Muster für Hintergrund und Papierfarbe neu und lädt es auf die GPU.
|
||||
fn upload_background_mesh(&mut self) {
|
||||
let mesh =
|
||||
generate_background_mesh(self.current_background, self.paper_theme.pattern_color());
|
||||
self.background_vertex_count = mesh.len() as u32;
|
||||
|
||||
if mesh.len() > self.background_vertex_capacity {
|
||||
@@ -522,7 +657,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
if !mesh.is_empty() {
|
||||
self.queue.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||
self.queue
|
||||
.write_buffer(&self.background_buffer, 0, bytemuck::cast_slice(&mesh));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +666,12 @@ impl CanvasState {
|
||||
pub fn sync_page(&mut self, page: &mut Page) {
|
||||
page.rebuild_cache_if_dirty();
|
||||
self.set_background(page.background);
|
||||
self.image_layer.set_quads(
|
||||
page.strokes
|
||||
.iter()
|
||||
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?)))
|
||||
.collect(),
|
||||
);
|
||||
|
||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||
self.committed_strokes = page.stroke_ranges.clone();
|
||||
@@ -539,7 +681,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
if page.vertex_cache.len() > self.committed_vertex_capacity {
|
||||
self.committed_vertex_capacity = (page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
|
||||
self.committed_vertex_capacity =
|
||||
(page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
|
||||
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
|
||||
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -555,6 +698,12 @@ impl CanvasState {
|
||||
);
|
||||
}
|
||||
|
||||
/// Lädt Bilder der aktuellen Seite, die noch nicht auf der GPU liegen. Vor jedem
|
||||
/// Zeichnen aufrufen; ohne Änderungen kostet das nichts.
|
||||
pub fn prepare_images(&mut self, assets: &crate::assets::AssetStore) {
|
||||
self.image_layer.prepare(&self.device, &self.queue, assets);
|
||||
}
|
||||
|
||||
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
|
||||
pub fn start_stroke(&mut self, page_x: f32, page_y: f32, pressure: f32, timestamp: f64) {
|
||||
self.filter.reset();
|
||||
@@ -635,6 +784,9 @@ impl CanvasState {
|
||||
self.current_ink_color,
|
||||
&self.spline_config,
|
||||
);
|
||||
let mut stroke = stroke;
|
||||
stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM;
|
||||
stroke.finalized = true;
|
||||
|
||||
// Temporären GPU-Live-Buffer vollständig leeren
|
||||
self.live_samples.clear();
|
||||
@@ -655,7 +807,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
let color = self.current_ink_color;
|
||||
let raw_strip = spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
|
||||
let raw_strip =
|
||||
spline::generate_triangle_strip(&self.live_control_points, &self.spline_config);
|
||||
self.live_vertices = raw_strip
|
||||
.into_iter()
|
||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||
@@ -667,7 +820,8 @@ impl CanvasState {
|
||||
}
|
||||
|
||||
if self.live_vertices.len() > self.live_vertex_capacity {
|
||||
self.live_vertex_capacity = (self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
||||
self.live_vertex_capacity =
|
||||
(self.live_vertices.len() * 2).max(Self::INITIAL_LIVE_CAPACITY);
|
||||
self.live_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Live Stroke Vertex Buffer (Reallocated)"),
|
||||
size: (self.live_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
@@ -694,7 +848,8 @@ impl CanvasState {
|
||||
) -> Result<(), wgpu::SurfaceError> {
|
||||
// Vor dem Renderpass: egui-Texturen aktualisieren
|
||||
for (id, image_delta) in &textures_delta.set {
|
||||
self.egui_renderer.update_texture(&self.device, &self.queue, *id, image_delta);
|
||||
self.egui_renderer
|
||||
.update_texture(&self.device, &self.queue, *id, image_delta);
|
||||
}
|
||||
|
||||
let output = self.surface.get_current_texture()?;
|
||||
@@ -725,7 +880,7 @@ impl CanvasState {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(DESK_COLOR), // Dunkler Tischhintergrund
|
||||
load: wgpu::LoadOp::Clear(self.paper_theme.desk_color()), // Tischhintergrund
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
@@ -737,7 +892,7 @@ impl CanvasState {
|
||||
// 2D-Kamera Uniforms (Scale & Offset) binden
|
||||
render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
|
||||
|
||||
// 1. Weißes DIN-A4-Blatt zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||
// 1. DIN-A4-Blatt in Papierfarbe zeichnen (Farbe ist direkt im Paper-Quad Vertex hinterlegt)
|
||||
render_pass.set_pipeline(&self.triangle_pipeline);
|
||||
render_pass.set_vertex_buffer(0, self.paper_buffer.slice(..));
|
||||
render_pass.draw(0..6, 0..1);
|
||||
@@ -748,7 +903,10 @@ impl CanvasState {
|
||||
render_pass.draw(0..self.background_vertex_count, 0..1);
|
||||
}
|
||||
|
||||
// 3. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
// 3. Eingefügte Bilder (unter der Tinte)
|
||||
self.image_layer.draw(&mut render_pass);
|
||||
|
||||
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
|
||||
if self.committed_vertex_count > 0 {
|
||||
@@ -760,7 +918,7 @@ impl CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||
// 5. Temporäre Live-Geometrie des aktuellen Strichs rendern (Zero Latency)
|
||||
if self.live_vertex_count > 0 {
|
||||
render_pass.set_vertex_buffer(0, self.live_vertex_buffer.slice(..));
|
||||
render_pass.draw(0..self.live_vertex_count, 0..1);
|
||||
@@ -784,7 +942,8 @@ impl CanvasState {
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
|
||||
self.egui_renderer.render(&mut egui_pass, clipped_primitives, screen_descriptor);
|
||||
self.egui_renderer
|
||||
.render(&mut egui_pass, clipped_primitives, screen_descriptor);
|
||||
}
|
||||
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
@@ -813,6 +972,16 @@ impl CanvasState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_choose_present_mode() {
|
||||
use wgpu::PresentMode::{Fifo, Immediate, Mailbox};
|
||||
assert_eq!(choose_present_mode(&[Fifo, Mailbox]), Some(Mailbox));
|
||||
assert_eq!(choose_present_mode(&[Fifo, Immediate]), Some(Fifo));
|
||||
assert_eq!(choose_present_mode(&[Immediate]), Some(Immediate));
|
||||
// Keine Darstellungsmodi (z. B. kein Zugriff auf /dev/dri): kein Absturz, sondern None
|
||||
assert_eq!(choose_present_mode(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pixel_to_ndc_mapping() {
|
||||
let width = 800.0;
|
||||
@@ -838,7 +1007,40 @@ mod tests {
|
||||
#[test]
|
||||
fn test_camera_uniforms() {
|
||||
let _u = CameraUniforms::default();
|
||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 16);
|
||||
// Muss dem WGSL-Layout entsprechen: vec2 + vec2 + vec4
|
||||
assert_eq!(std::mem::size_of::<CameraUniforms>(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shader_is_valid_and_matches_uniforms() {
|
||||
// Ein Shaderfehler fiele sonst erst beim Programmstart auf
|
||||
let module = naga::front::wgsl::parse_str(include_str!("shader.wgsl"))
|
||||
.unwrap_or_else(|e| panic!("WGSL-Fehler: {e}"));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("Shader ungültig: {e:?}"));
|
||||
|
||||
for name in ["vs_main", "fs_main", "fs_ink", "vs_image", "fs_image"] {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|ep| ep.name == name),
|
||||
"Einstiegspunkt {name} fehlt"
|
||||
);
|
||||
}
|
||||
let camera = module
|
||||
.types
|
||||
.iter()
|
||||
.find(|(_, ty)| ty.name.as_deref() == Some("CameraUniforms"))
|
||||
.expect("CameraUniforms fehlt im Shader")
|
||||
.1;
|
||||
match camera.inner {
|
||||
naga::TypeInner::Struct { span, .. } => {
|
||||
assert_eq!(span as usize, std::mem::size_of::<CameraUniforms>())
|
||||
}
|
||||
ref other => panic!("CameraUniforms ist kein Struct: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+467
-34
@@ -103,6 +103,95 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) ->
|
||||
(p - projection).length()
|
||||
}
|
||||
|
||||
/// Liegt `p` im konvexen Viereck `quad` (Ecken der Reihe nach, beliebiger Umlaufsinn)?
|
||||
pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
|
||||
let mut sign = 0.0_f32;
|
||||
for i in 0..4 {
|
||||
let a = quad[i];
|
||||
let b = quad[(i + 1) % 4];
|
||||
let cross = (b - a).perp_dot(p - a);
|
||||
if cross.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = cross.signum();
|
||||
} else if cross.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
|
||||
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
|
||||
|
||||
/// Ecken (oben links, oben rechts, unten rechts, unten links) für ein neu eingefügtes Bild
|
||||
/// von `width` × `height` Pixeln um `center`: Bildschirmgröße bei 96 dpi, höchstens 80 % der
|
||||
/// Seitenbreite und 60 % der Seitenhöhe, und möglichst ganz auf dem Blatt.
|
||||
pub fn place_image(width: u32, height: u32, center: glam::Vec2) -> [glam::Vec2; 4] {
|
||||
let natural =
|
||||
glam::Vec2::new(width.max(1) as f32, height.max(1) as f32) * IMAGE_UNITS_PER_PIXEL;
|
||||
let fit = (PAGE_WIDTH * 0.8 / natural.x)
|
||||
.min(PAGE_HEIGHT * 0.6 / natural.y)
|
||||
.min(1.0);
|
||||
let size = natural * fit;
|
||||
let half = size / 2.0;
|
||||
let center = glam::Vec2::new(
|
||||
center.x.clamp(half.x, PAGE_WIDTH - half.x),
|
||||
center.y.clamp(half.y, PAGE_HEIGHT - half.y),
|
||||
);
|
||||
let tl = center - half;
|
||||
[
|
||||
tl,
|
||||
tl + glam::Vec2::new(size.x, 0.0),
|
||||
tl + size,
|
||||
tl + glam::Vec2::new(0.0, size.y),
|
||||
]
|
||||
}
|
||||
|
||||
/// Das Werkzeug, mit dem ein Strich gezeichnet wurde.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StrokeTool {
|
||||
#[default]
|
||||
Pen,
|
||||
Highlighter,
|
||||
Shape,
|
||||
}
|
||||
|
||||
impl StrokeTool {
|
||||
/// Werkzeug eines gespeicherten Strichs. Bis v0.3.7 wurde das Werkzeug nicht erfasst und
|
||||
/// jeder Strich als `pen` gespeichert; Textmarker erkennt man dann an der
|
||||
/// Halbtransparenz (der Stift ist immer deckend), Formen an der erkannten Form.
|
||||
pub fn resolve_stored(stored: StrokeTool, color: [f32; 4], has_shape: bool) -> StrokeTool {
|
||||
match stored {
|
||||
StrokeTool::Pen if has_shape => StrokeTool::Shape,
|
||||
StrokeTool::Pen if color[3] < 0.99 => StrokeTool::Highlighter,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Welche Striche der Radierer entfernt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EraserMode {
|
||||
/// Alle getroffenen Striche
|
||||
#[default]
|
||||
All,
|
||||
/// Nur Textmarker – die Handschrift darunter bleibt erhalten
|
||||
HighlighterOnly,
|
||||
}
|
||||
|
||||
impl EraserMode {
|
||||
/// Darf ein Strich dieses Werkzeugs radiert werden?
|
||||
pub fn erases(self, tool: StrokeTool) -> bool {
|
||||
match self {
|
||||
EraserMode::All => true,
|
||||
EraserMode::HighlighterOnly => tool == StrokeTool::Highlighter,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein einzelner Vektor-Strich auf einer Seite.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Stroke {
|
||||
@@ -112,15 +201,30 @@ pub struct Stroke {
|
||||
pub aabb: Aabb,
|
||||
pub color: [f32; 4],
|
||||
pub shape: Option<crate::geometry::RecognizedShape>,
|
||||
pub tool: StrokeTool,
|
||||
pub width: f32,
|
||||
pub finalized: bool,
|
||||
/// Eingefügtes Bild (Name im Asset-Ordner, siehe `assets`). Die vier Punkte sind dann die
|
||||
/// Bildecken oben links, oben rechts, unten rechts, unten links; Verschieben, Drehen und
|
||||
/// Skalieren wirken so automatisch auch auf Bilder. Bilder liegen immer unter der Tinte.
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl Stroke {
|
||||
pub fn new(id: u64, points: Vec<StylusSample>, vertices: Vec<InkVertex>, color: [f32; 4]) -> Self {
|
||||
pub fn new(
|
||||
id: u64,
|
||||
points: Vec<StylusSample>,
|
||||
vertices: Vec<InkVertex>,
|
||||
color: [f32; 4],
|
||||
) -> Self {
|
||||
let aabb = if !points.is_empty() {
|
||||
let pts: Vec<glam::Vec2> = points.iter().map(|s| s.position).collect();
|
||||
Aabb::from_points(&pts)
|
||||
} else if !vertices.is_empty() {
|
||||
let pts: Vec<glam::Vec2> = vertices.iter().map(|v| glam::Vec2::new(v.position[0], v.position[1])).collect();
|
||||
let pts: Vec<glam::Vec2> = vertices
|
||||
.iter()
|
||||
.map(|v| glam::Vec2::new(v.position[0], v.position[1]))
|
||||
.collect();
|
||||
Aabb::from_points(&pts)
|
||||
} else {
|
||||
Aabb::new(glam::Vec2::ZERO, glam::Vec2::ZERO)
|
||||
@@ -133,9 +237,47 @@ impl Stroke {
|
||||
aabb,
|
||||
color,
|
||||
shape: None,
|
||||
tool: StrokeTool::Pen,
|
||||
width: 5.0,
|
||||
finalized: true,
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt ein Bildobjekt mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||
pub fn new_image(id: u64, asset: String, corners: [glam::Vec2; 4]) -> Self {
|
||||
let points = corners
|
||||
.iter()
|
||||
.map(|&position| StylusSample {
|
||||
position,
|
||||
pressure: 1.0,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.collect();
|
||||
let mut stroke = Self::new(id, points, Vec::new(), [1.0, 1.0, 1.0, 1.0]);
|
||||
stroke.width = 0.0;
|
||||
stroke.image = Some(asset);
|
||||
stroke
|
||||
}
|
||||
|
||||
/// Ist dieses Objekt ein Bild?
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.image.is_some()
|
||||
}
|
||||
|
||||
/// Bildecken (oben links, oben rechts, unten rechts, unten links), falls ein Bild.
|
||||
pub fn image_corners(&self) -> Option<[glam::Vec2; 4]> {
|
||||
if self.image.is_none() || self.points.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
Some([
|
||||
self.points[0].position,
|
||||
self.points[1].position,
|
||||
self.points[2].position,
|
||||
self.points[3].position,
|
||||
])
|
||||
}
|
||||
|
||||
/// Finalisiert rohe Stylus-Samples mit akausalem Post-Processing (Zwei-Stufen-Modell).
|
||||
pub fn finalize(
|
||||
id: u64,
|
||||
@@ -149,11 +291,36 @@ impl Stroke {
|
||||
/// Verknüpft diesen Strich mit einer mathematisch erkannten Form.
|
||||
pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self {
|
||||
self.shape = Some(shape);
|
||||
self.tool = StrokeTool::Shape;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tool(mut self, tool: StrokeTool) -> Self {
|
||||
self.tool = tool;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_width(mut self, width: f32) -> Self {
|
||||
self.width = width;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_finalized(mut self, finalized: bool) -> Self {
|
||||
self.finalized = finalized;
|
||||
self
|
||||
}
|
||||
|
||||
/// Berechnet den minimalen euklidischen Abstand eines Punktes zu diesem Strich.
|
||||
/// Bei Bildern ist der Abstand innerhalb der Bildfläche 0.
|
||||
pub fn distance_to_point(&self, p: glam::Vec2) -> f32 {
|
||||
if let Some(corners) = self.image_corners() {
|
||||
if point_in_convex_quad(p, &corners) {
|
||||
return 0.0;
|
||||
}
|
||||
return (0..4)
|
||||
.map(|i| point_to_segment_distance(p, corners[i], corners[(i + 1) % 4]))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
}
|
||||
if self.points.is_empty() {
|
||||
return f32::INFINITY;
|
||||
}
|
||||
@@ -171,7 +338,12 @@ impl Stroke {
|
||||
}
|
||||
|
||||
/// Prüft, ob dieser Strich von einem Radierer an Position `eraser_pos` mit Radius `eraser_radius` getroffen wird.
|
||||
pub fn intersects_eraser(&self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> bool {
|
||||
pub fn intersects_eraser(
|
||||
&self,
|
||||
eraser_pos: glam::Vec2,
|
||||
eraser_radius: f32,
|
||||
max_stroke_radius: f32,
|
||||
) -> bool {
|
||||
// 1. Vorabtest: Schneidet der Radierer die vergrößerte AABB?
|
||||
let total_margin = eraser_radius + max_stroke_radius;
|
||||
if !self.aabb.expanded(total_margin).contains_point(eraser_pos) {
|
||||
@@ -254,9 +426,7 @@ impl Stroke {
|
||||
|
||||
/// Skaliert den Strich bezüglich `origin` um die Faktoren `factors`.
|
||||
pub fn scale(&mut self, origin: glam::Vec2, factors: glam::Vec2) {
|
||||
let sc = |pos: glam::Vec2| -> glam::Vec2 {
|
||||
origin + (pos - origin) * factors
|
||||
};
|
||||
let sc = |pos: glam::Vec2| -> glam::Vec2 { origin + (pos - origin) * factors };
|
||||
|
||||
for pt in &mut self.points {
|
||||
pt.position = sc(pt.position);
|
||||
@@ -272,7 +442,11 @@ impl Stroke {
|
||||
}
|
||||
|
||||
/// Weist diesem Strich eine neue Farbe zu und aktualisiert alle Vertex-Farben.
|
||||
/// Bilder behalten ihre Farben.
|
||||
pub fn set_color(&mut self, new_color: [f32; 4]) {
|
||||
if self.is_image() {
|
||||
return;
|
||||
}
|
||||
self.color = new_color;
|
||||
for v in &mut self.vertices {
|
||||
v.color = new_color;
|
||||
@@ -293,6 +467,22 @@ pub enum PageBackground {
|
||||
DotGrid,
|
||||
}
|
||||
|
||||
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PersistState {
|
||||
#[default]
|
||||
Normal,
|
||||
ReadOnlyAfterLoadError,
|
||||
}
|
||||
|
||||
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageLoadWarning {
|
||||
pub page_id: u64,
|
||||
pub message: String,
|
||||
pub quarantine_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Page {
|
||||
@@ -303,6 +493,7 @@ pub struct Page {
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
pub is_loaded: bool,
|
||||
pub persist: PersistState,
|
||||
pub history: HistoryStack,
|
||||
}
|
||||
|
||||
@@ -316,6 +507,7 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: true,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
@@ -330,10 +522,19 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: false,
|
||||
persist: PersistState::Normal,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_readonly(&self) -> bool {
|
||||
self.persist == PersistState::ReadOnlyAfterLoadError
|
||||
}
|
||||
|
||||
pub fn unlock_after_load_error(&mut self) {
|
||||
self.persist = PersistState::Normal;
|
||||
}
|
||||
|
||||
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
|
||||
pub fn add_stroke(&mut self, stroke: Stroke) {
|
||||
self.history.push(HistoryAction::AddStroke(stroke.clone()));
|
||||
@@ -359,20 +560,18 @@ impl Page {
|
||||
}
|
||||
|
||||
/// Findet den nächsten Strich an Position `pos` innerhalb eines maximalen Suchradius.
|
||||
/// Tinte hat Vorrang vor Bildern darunter, damit Notizen auf einem Bild auswählbar bleiben.
|
||||
pub fn find_stroke_at(&self, pos: glam::Vec2, max_dist: f32) -> Option<&Stroke> {
|
||||
let mut best: Option<(&Stroke, f32)> = None;
|
||||
let mut best: Option<(&Stroke, (bool, f32))> = None;
|
||||
for s in &self.strokes {
|
||||
if !s.aabb.expanded(max_dist).contains_point(pos) {
|
||||
continue;
|
||||
}
|
||||
let d = s.distance_to_point(pos);
|
||||
if d <= max_dist {
|
||||
if let Some((_, best_d)) = best {
|
||||
if d < best_d {
|
||||
best = Some((s, d));
|
||||
}
|
||||
} else {
|
||||
best = Some((s, d));
|
||||
let key = (s.is_image(), d);
|
||||
if best.map_or(true, |(_, best_key)| key < best_key) {
|
||||
best = Some((s, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,14 +598,24 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`.
|
||||
/// Radiert alle Striche an der Position `eraser_pos` mit Radius `eraser_radius`,
|
||||
/// die `mode` zulässt.
|
||||
///
|
||||
/// Gibt die gelöschten Striche zurück (falls welche getroffen wurden).
|
||||
pub fn erase_at(&mut self, eraser_pos: glam::Vec2, eraser_radius: f32, max_stroke_radius: f32) -> Vec<Stroke> {
|
||||
pub fn erase_at(
|
||||
&mut self,
|
||||
eraser_pos: glam::Vec2,
|
||||
eraser_radius: f32,
|
||||
max_stroke_radius: f32,
|
||||
mode: EraserMode,
|
||||
) -> Vec<Stroke> {
|
||||
let mut hit_indices = Vec::new();
|
||||
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
if stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius) {
|
||||
if !stroke.is_image()
|
||||
&& mode.erases(stroke.tool)
|
||||
&& stroke.intersects_eraser(eraser_pos, eraser_radius, max_stroke_radius)
|
||||
{
|
||||
hit_indices.push(i);
|
||||
}
|
||||
}
|
||||
@@ -422,7 +631,8 @@ impl Page {
|
||||
}
|
||||
|
||||
removed.reverse();
|
||||
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.history
|
||||
.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.is_dirty = true;
|
||||
|
||||
removed
|
||||
@@ -459,6 +669,8 @@ impl Page {
|
||||
}
|
||||
|
||||
/// Findet die IDs aller Striche, die die gegebene Auswahlbox schneiden oder darin liegen.
|
||||
/// Bilder werden nur ausgewählt, wenn sie ganz in der Box liegen – sonst würde jede
|
||||
/// Auswahl von Notizen auf einem Bild das Bild mitnehmen.
|
||||
pub fn find_strokes_in_box(&self, box_min: glam::Vec2, box_max: glam::Vec2) -> Vec<u64> {
|
||||
let sel_box = Aabb::new(
|
||||
glam::Vec2::new(box_min.x.min(box_max.x), box_min.y.min(box_max.y)),
|
||||
@@ -466,14 +678,24 @@ impl Page {
|
||||
);
|
||||
self.strokes
|
||||
.iter()
|
||||
.filter(|s| s.aabb.intersects(&sel_box))
|
||||
.filter(|s| {
|
||||
if s.is_image() {
|
||||
sel_box.contains_point(s.aabb.min) && sel_box.contains_point(s.aabb.max)
|
||||
} else {
|
||||
s.aabb.intersects(&sel_box)
|
||||
}
|
||||
})
|
||||
.map(|s| s.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Liefert Klone der Striche mit den angegebenen IDs.
|
||||
pub fn get_strokes(&self, ids: &[u64]) -> Vec<Stroke> {
|
||||
self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect()
|
||||
self.strokes
|
||||
.iter()
|
||||
.filter(|s| ids.contains(&s.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Aktualisiert eine Menge geänderter Striche transaktional im History-Stack.
|
||||
@@ -482,7 +704,12 @@ impl Page {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<u64> = modified.iter().map(|s| s.id).collect();
|
||||
let before: Vec<Stroke> = self.strokes.iter().filter(|s| ids.contains(&s.id)).cloned().collect();
|
||||
let before: Vec<Stroke> = self
|
||||
.strokes
|
||||
.iter()
|
||||
.filter(|s| ids.contains(&s.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for m in modified.iter() {
|
||||
if let Some(pos) = self.strokes.iter().position(|s| s.id == m.id) {
|
||||
@@ -517,7 +744,8 @@ impl Page {
|
||||
self.strokes[pos] = m.clone();
|
||||
}
|
||||
}
|
||||
self.history.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.history
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.is_dirty = true;
|
||||
}
|
||||
|
||||
@@ -534,7 +762,8 @@ impl Page {
|
||||
});
|
||||
|
||||
if !removed.is_empty() {
|
||||
self.history.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.history
|
||||
.push(HistoryAction::RemoveStrokes(removed.clone()));
|
||||
self.is_dirty = true;
|
||||
}
|
||||
removed
|
||||
@@ -545,7 +774,8 @@ impl Page {
|
||||
if new_strokes.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.history.push(HistoryAction::AddStrokes(new_strokes.clone()));
|
||||
self.history
|
||||
.push(HistoryAction::AddStrokes(new_strokes.clone()));
|
||||
self.strokes.extend(new_strokes);
|
||||
self.is_dirty = true;
|
||||
}
|
||||
@@ -558,6 +788,7 @@ pub struct Notebook {
|
||||
pub current_page_index: usize,
|
||||
pub next_stroke_id: u64,
|
||||
pub next_page_id: u64,
|
||||
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
|
||||
}
|
||||
|
||||
impl Notebook {
|
||||
@@ -567,6 +798,7 @@ impl Notebook {
|
||||
current_page_index: 0,
|
||||
next_stroke_id: 1,
|
||||
next_page_id: 1,
|
||||
load_warnings: std::collections::HashMap::new(),
|
||||
};
|
||||
// Erste Standardseite anlegen (5mm Grid)
|
||||
nb.add_page(PageBackground::Grid5mm);
|
||||
@@ -585,6 +817,7 @@ impl Notebook {
|
||||
current_page_index,
|
||||
next_stroke_id,
|
||||
next_page_id,
|
||||
load_warnings: std::collections::HashMap::new(),
|
||||
};
|
||||
if nb.pages.is_empty() {
|
||||
nb.add_page(PageBackground::Grid5mm);
|
||||
@@ -653,7 +886,11 @@ impl Notebook {
|
||||
|
||||
/// Text für das On-Screen-Display (OSD), z. B. "Seite 3 / 12".
|
||||
pub fn osd_text(&self) -> String {
|
||||
format!("Seite {} / {}", self.current_page_index + 1, self.pages.len())
|
||||
format!(
|
||||
"Seite {} / {}",
|
||||
self.current_page_index + 1,
|
||||
self.pages.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +914,21 @@ pub struct PageViewport {
|
||||
impl PageViewport {
|
||||
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
||||
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
||||
let avail_w = (window_width - 2.0 * margin).max(10.0);
|
||||
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
|
||||
}
|
||||
|
||||
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
|
||||
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
|
||||
pub fn calculate_with_insets(
|
||||
window_width: f32,
|
||||
window_height: f32,
|
||||
margin: f32,
|
||||
inset_left: f32,
|
||||
inset_right: f32,
|
||||
) -> Self {
|
||||
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
|
||||
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
|
||||
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
|
||||
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
||||
|
||||
let window_ratio = avail_w / avail_h;
|
||||
@@ -695,7 +946,7 @@ impl PageViewport {
|
||||
(w, h)
|
||||
};
|
||||
|
||||
let page_x = (window_width - page_w) / 2.0;
|
||||
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
|
||||
let page_y = (window_height - page_h) / 2.0;
|
||||
|
||||
Self {
|
||||
@@ -787,13 +1038,25 @@ mod tests {
|
||||
let stroke_radius = 2.0;
|
||||
|
||||
// Treffer: Radierer bei (150, 105) (Abstand 5 <= 12)
|
||||
assert!(stroke.intersects_eraser(glam::Vec2::new(150.0, 105.0), eraser_radius, stroke_radius));
|
||||
assert!(stroke.intersects_eraser(
|
||||
glam::Vec2::new(150.0, 105.0),
|
||||
eraser_radius,
|
||||
stroke_radius
|
||||
));
|
||||
|
||||
// Kein Treffer: Radierer bei (150, 150) (Abstand 50 > 12)
|
||||
assert!(!stroke.intersects_eraser(glam::Vec2::new(150.0, 150.0), eraser_radius, stroke_radius));
|
||||
assert!(!stroke.intersects_eraser(
|
||||
glam::Vec2::new(150.0, 150.0),
|
||||
eraser_radius,
|
||||
stroke_radius
|
||||
));
|
||||
|
||||
// Kein Treffer: Weit außerhalb der AABB
|
||||
assert!(!stroke.intersects_eraser(glam::Vec2::new(500.0, 500.0), eraser_radius, stroke_radius));
|
||||
assert!(!stroke.intersects_eraser(
|
||||
glam::Vec2::new(500.0, 500.0),
|
||||
eraser_radius,
|
||||
stroke_radius
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -824,6 +1087,25 @@ mod tests {
|
||||
assert_eq!(nb.current_page_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_viewport_insets_center_page_in_free_area() {
|
||||
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
|
||||
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
|
||||
assert!((inset.page_width - plain.page_width).abs() < 0.01);
|
||||
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
|
||||
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
|
||||
|
||||
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
|
||||
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
|
||||
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
|
||||
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
|
||||
|
||||
// Leiste rechts
|
||||
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
|
||||
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_viewport_projection() {
|
||||
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||
@@ -900,7 +1182,7 @@ mod tests {
|
||||
assert_eq!(page.strokes.len(), 2);
|
||||
|
||||
// Radieren bei (30, 12): Trifft nur s1
|
||||
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0);
|
||||
let removed = page.erase_at(glam::Vec2::new(30.0, 12.0), 10.0, 2.0, EraserMode::All);
|
||||
assert_eq!(removed.len(), 1);
|
||||
assert_eq!(removed[0].id, 1);
|
||||
assert_eq!(page.strokes.len(), 1);
|
||||
@@ -916,6 +1198,148 @@ mod tests {
|
||||
assert_eq!(page.strokes[0].id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_erase_highlighter_only_keeps_handwriting() {
|
||||
let mut page = Page::new(1, PageBackground::Grid5mm);
|
||||
let line = |id: u64, tool: StrokeTool| {
|
||||
let mut stroke = Stroke::new(
|
||||
id,
|
||||
vec![
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(10.0, 10.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(50.0, 10.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.01,
|
||||
},
|
||||
],
|
||||
vec![
|
||||
InkVertex::new(10.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
|
||||
InkVertex::new(50.0, 10.0, [0.0, 0.0, 0.0, 1.0]),
|
||||
],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
stroke.tool = tool;
|
||||
stroke
|
||||
};
|
||||
// Handschrift, Form und Textmarker liegen übereinander
|
||||
page.add_stroke(line(1, StrokeTool::Pen));
|
||||
page.add_stroke(line(2, StrokeTool::Highlighter));
|
||||
page.add_stroke(line(3, StrokeTool::Shape));
|
||||
|
||||
let at = glam::Vec2::new(30.0, 10.0);
|
||||
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly);
|
||||
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
|
||||
assert_eq!(
|
||||
page.strokes.iter().map(|s| s.id).collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
// Kein Textmarker mehr da: nichts wird entfernt, kein Undo-Eintrag
|
||||
assert!(page
|
||||
.erase_at(at, 10.0, 2.0, EraserMode::HighlighterOnly)
|
||||
.is_empty());
|
||||
assert!(page.undo());
|
||||
assert_eq!(page.strokes.len(), 3);
|
||||
|
||||
// Normaler Radierer entfernt alles
|
||||
let removed = page.erase_at(at, 10.0, 2.0, EraserMode::All);
|
||||
assert_eq!(removed.len(), 3);
|
||||
assert!(page.strokes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_place_image() {
|
||||
let center = glam::Vec2::new(PAGE_WIDTH / 2.0, PAGE_HEIGHT / 2.0);
|
||||
// Kleines Bild: Bildschirmgröße, zentriert
|
||||
let [tl, tr, br, bl] = place_image(200, 100, center);
|
||||
assert!(((tr - tl).length() - 200.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
||||
assert!(((bl - tl).length() - 100.0 * IMAGE_UNITS_PER_PIXEL).abs() < 1e-3);
|
||||
assert!(((tl + br) / 2.0 - center).length() < 1e-3);
|
||||
|
||||
// Großer Screenshot: auf 80 % der Breite verkleinert, Seitenverhältnis bleibt
|
||||
let [tl, tr, br, _] = place_image(3840, 2160, center);
|
||||
let size = br - tl;
|
||||
assert!((size.x - PAGE_WIDTH * 0.8).abs() < 1e-2);
|
||||
assert!((size.x / size.y - 3840.0 / 2160.0).abs() < 1e-3);
|
||||
assert_eq!(tr.y, tl.y);
|
||||
|
||||
// Am Rand eingefügt: wird aufs Blatt geschoben
|
||||
let corners = place_image(400, 400, glam::Vec2::new(-50.0, PAGE_HEIGHT + 50.0));
|
||||
for c in corners {
|
||||
assert!(c.x >= -1e-3 && c.x <= PAGE_WIDTH + 1e-3);
|
||||
assert!(c.y >= -1e-3 && c.y <= PAGE_HEIGHT + 1e-3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_objects_hit_test_and_survive_erasing() {
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
let corners = [
|
||||
glam::Vec2::new(100.0, 100.0),
|
||||
glam::Vec2::new(300.0, 100.0),
|
||||
glam::Vec2::new(300.0, 200.0),
|
||||
glam::Vec2::new(100.0, 200.0),
|
||||
];
|
||||
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
|
||||
assert_eq!(page.strokes[0].image_corners(), Some(corners));
|
||||
|
||||
// Fläche des Bildes trifft, auch weit weg vom Rand
|
||||
let inside = glam::Vec2::new(200.0, 150.0);
|
||||
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(1));
|
||||
assert!(page
|
||||
.find_stroke_at(glam::Vec2::new(400.0, 150.0), 5.0)
|
||||
.is_none());
|
||||
|
||||
// Notiz auf dem Bild hat beim Antippen Vorrang
|
||||
let mut ink = Stroke::new(
|
||||
2,
|
||||
vec![
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(150.0, 150.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(250.0, 152.0),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.1,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
);
|
||||
ink.width = 5.0;
|
||||
page.add_stroke(ink);
|
||||
assert_eq!(page.find_stroke_at(inside, 5.0).map(|s| s.id), Some(2));
|
||||
|
||||
// Radierer entfernt nur die Notiz, nie das Bild
|
||||
let removed = page.erase_at(inside, 30.0, 12.0, EraserMode::All);
|
||||
assert_eq!(removed.iter().map(|s| s.id).collect::<Vec<_>>(), vec![2]);
|
||||
assert!(page
|
||||
.erase_at(glam::Vec2::new(100.0, 100.0), 30.0, 12.0, EraserMode::All)
|
||||
.is_empty());
|
||||
|
||||
// Auswahlrechteck: Bild nur bei vollständigem Einschluss
|
||||
assert!(page
|
||||
.find_strokes_in_box(glam::Vec2::new(150.0, 120.0), glam::Vec2::new(250.0, 180.0))
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
page.find_strokes_in_box(glam::Vec2::new(90.0, 90.0), glam::Vec2::new(310.0, 210.0)),
|
||||
vec![1]
|
||||
);
|
||||
|
||||
// Umfärben ändert Bilder nicht, Drehen bewegt die Ecken mit
|
||||
let mut image = page.strokes[0].clone();
|
||||
image.set_color([1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(image.color, [1.0, 1.0, 1.0, 1.0]);
|
||||
image.rotate(glam::Vec2::new(200.0, 150.0), std::f32::consts::FRAC_PI_2);
|
||||
let rotated = image.image_corners().unwrap();
|
||||
assert!((rotated[0] - glam::Vec2::new(250.0, 50.0)).length() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_isolation() {
|
||||
let mut nb = Notebook::new();
|
||||
@@ -966,9 +1390,12 @@ mod tests {
|
||||
let mut moved = s.clone();
|
||||
moved.translate(glam::Vec2::new(20.0, 30.0));
|
||||
page.update_strokes_ephemeral(&[moved.clone()]);
|
||||
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
|
||||
assert_eq!(
|
||||
page.strokes[0].points[0].position,
|
||||
glam::Vec2::new(30.0, 40.0)
|
||||
);
|
||||
// History should not have been updated yet
|
||||
assert_eq!(page.history.can_redo(), false);
|
||||
assert!(!page.history.can_redo());
|
||||
|
||||
// Transactional commit (mouse release)
|
||||
let before = vec![s.clone()];
|
||||
@@ -977,10 +1404,16 @@ mod tests {
|
||||
|
||||
// Verify Undo restores original position
|
||||
assert!(page.undo());
|
||||
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(10.0, 10.0));
|
||||
assert_eq!(
|
||||
page.strokes[0].points[0].position,
|
||||
glam::Vec2::new(10.0, 10.0)
|
||||
);
|
||||
|
||||
// Verify Redo applies modified position
|
||||
assert!(page.redo());
|
||||
assert_eq!(page.strokes[0].points[0].position, glam::Vec2::new(30.0, 40.0));
|
||||
assert_eq!(
|
||||
page.strokes[0].points[0].position,
|
||||
glam::Vec2::new(30.0, 40.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
+541
-96
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Features:
|
||||
//! - Hochformat DIN A4 (210 mm x 297 mm)
|
||||
//! - 2 Layer pro Seite: Layer 1 ("Background") und Layer 2 ("Ink")
|
||||
//! - Layer pro Seite: "Background", "Images" (nur bei eingefügten Bildern) und "Ink"
|
||||
//! - Y-Achsen-Spiegelung (PDF-Ursprung unten links vs. Bildschirmursprung oben links)
|
||||
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
|
||||
//! - Zwei Export-Strategien:
|
||||
@@ -12,16 +12,81 @@
|
||||
use std::error::Error;
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::Path;
|
||||
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::background::{GRID_5MM_SPACING, LINED_SPACING};
|
||||
use crate::document::{
|
||||
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
|
||||
};
|
||||
|
||||
/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen).
|
||||
pub fn sanitize_filename_component(title: &str) -> String {
|
||||
let sanitized: String = title
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim();
|
||||
if trimmed.is_empty() {
|
||||
"Notizbuch".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents).
|
||||
pub fn default_documents_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") {
|
||||
if !dir.trim().is_empty() {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.trim().is_empty() {
|
||||
return PathBuf::from(home).join("Documents");
|
||||
}
|
||||
}
|
||||
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
/// Ermittelt den kollisionsfreien Export-Pfad für ein Notizbuch im übergebenen Verzeichnis.
|
||||
/// Existiert die Datei bereits, wird ein Zähler angehängt (`_1`, `_2`, etc.).
|
||||
pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf {
|
||||
let sanitized_title = sanitize_filename_component(title);
|
||||
let base_name = format!("{}.pdf", sanitized_title);
|
||||
let primary_path = dir.join(&base_name);
|
||||
|
||||
if !primary_path.exists() {
|
||||
return primary_path;
|
||||
}
|
||||
|
||||
let mut counter = 1;
|
||||
loop {
|
||||
let candidate_name = format!("{}_{}.pdf", sanitized_title, counter);
|
||||
let candidate_path = dir.join(candidate_name);
|
||||
if !candidate_path.exists() {
|
||||
return candidate_path;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR
|
||||
/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig.
|
||||
pub fn resolve_pdf_export_path(title: &str) -> PathBuf {
|
||||
let docs_dir = default_documents_dir();
|
||||
let _ = std::fs::create_dir_all(&docs_dir);
|
||||
resolve_pdf_export_path_in_dir(&docs_dir, title)
|
||||
}
|
||||
|
||||
/// Wählbare Exportstrategie für Vektorstriche.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ExportStrategy {
|
||||
@@ -45,14 +110,16 @@ pub fn page_to_pdf_mm(x: f32, y: f32) -> (f32, f32) {
|
||||
}
|
||||
|
||||
/// Erzeugt ein PDF-Dokument aus einem Notizbuch und speichert es als Datei.
|
||||
/// `assets`: Bilder des Notizbuchs (ohne werden Bilder als graue Fläche ausgegeben).
|
||||
pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
||||
notebook: &Notebook,
|
||||
path: P,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
let doc = generate_pdf_document(notebook, strategy)?;
|
||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||
doc.save(&mut writer)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,8 +128,9 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
||||
pub fn export_notebook_to_bytes(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
let doc = generate_pdf_document(notebook, strategy)?;
|
||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||
let bytes = doc.save_to_bytes()?;
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -71,38 +139,47 @@ pub fn export_notebook_to_bytes(
|
||||
pub fn generate_pdf_document(
|
||||
notebook: &Notebook,
|
||||
strategy: ExportStrategy,
|
||||
assets: Option<&AssetStore>,
|
||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||
if notebook.pages.is_empty() {
|
||||
return Err("Notizbuch enthält keine Seiten".into());
|
||||
}
|
||||
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
|
||||
if let Some(page) = notebook.pages.iter().find(|p| {
|
||||
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
|
||||
}) {
|
||||
return Err(format!(
|
||||
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
|
||||
page.id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// 1. Erstes Dokument mit Seite 1 erstellen
|
||||
let title = "ink-notes Notebook";
|
||||
let (doc, page1_idx, layer1_bg) = PdfDocument::new(
|
||||
title,
|
||||
Mm(A4_WIDTH_MM),
|
||||
Mm(A4_HEIGHT_MM),
|
||||
"Background",
|
||||
);
|
||||
let (doc, page1_idx, layer1_bg) =
|
||||
PdfDocument::new(title, Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||
|
||||
// Seite 1 befüllen
|
||||
let page1_ref = doc.get_page(page1_idx);
|
||||
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
|
||||
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
|
||||
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
||||
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
||||
}
|
||||
|
||||
let layer1_ink_ref = page1_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
||||
|
||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||
for page in ¬ebook.pages[1..] {
|
||||
let (page_idx, layer_bg) = doc.add_page(
|
||||
Mm(A4_WIDTH_MM),
|
||||
Mm(A4_HEIGHT_MM),
|
||||
"Background",
|
||||
);
|
||||
let (page_idx, layer_bg) = doc.add_page(Mm(A4_WIDTH_MM), Mm(A4_HEIGHT_MM), "Background");
|
||||
let page_ref = doc.get_page(page_idx);
|
||||
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
||||
render_background_layer(&layer_bg_ref, page.background);
|
||||
if page.strokes.iter().any(Stroke::is_image) {
|
||||
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
||||
}
|
||||
|
||||
let layer_ink_ref = page_ref.add_layer("Ink");
|
||||
render_ink_layer(&layer_ink_ref, page, strategy);
|
||||
@@ -112,10 +189,7 @@ pub fn generate_pdf_document(
|
||||
}
|
||||
|
||||
/// Rendert das Vektorraster auf Layer 1 ("Background").
|
||||
fn render_background_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
background: PageBackground,
|
||||
) {
|
||||
fn render_background_layer(layer: &printpdf::PdfLayerReference, background: PageBackground) {
|
||||
let grid_color = Color::Rgb(Rgb::new(0.88, 0.88, 0.88, None));
|
||||
layer.set_outline_color(grid_color.clone());
|
||||
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
|
||||
@@ -203,12 +277,11 @@ fn render_background_layer(
|
||||
}
|
||||
|
||||
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
|
||||
fn render_ink_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
strategy: ExportStrategy,
|
||||
) {
|
||||
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
|
||||
for stroke in &page.strokes {
|
||||
if stroke.is_image() {
|
||||
continue;
|
||||
}
|
||||
if let Some(ref shape) = stroke.shape {
|
||||
render_recognized_shape(layer, shape, stroke.color);
|
||||
} else {
|
||||
@@ -224,6 +297,160 @@ fn render_ink_layer(
|
||||
}
|
||||
}
|
||||
|
||||
/// Auflösung, mit der Bilder höchstens eingebettet werden (Druckqualität).
|
||||
pub const PDF_IMAGE_DPI: f32 = 300.0;
|
||||
|
||||
/// Seitenkoordinate in PDF-Punkten (ohne Begrenzung auf das Blatt, Ursprung unten links).
|
||||
fn page_to_pdf_pt(p: glam::Vec2) -> glam::Vec2 {
|
||||
const PT_PER_MM: f32 = 72.0 / 25.4;
|
||||
glam::Vec2::new(
|
||||
p.x / PAGE_WIDTH * A4_WIDTH_MM * PT_PER_MM,
|
||||
(1.0 - p.y / PAGE_HEIGHT) * A4_HEIGHT_MM * PT_PER_MM,
|
||||
)
|
||||
}
|
||||
|
||||
/// PDF-Transformationsmatrix, die das Einheitsquadrat eines Bildes (Ursprung unten links)
|
||||
/// auf die Bildecken oben links, oben rechts, unten rechts, unten links abbildet.
|
||||
pub fn image_matrix(corners: &[glam::Vec2; 4]) -> [f32; 6] {
|
||||
let [tl, _, br, bl] = corners.map(page_to_pdf_pt);
|
||||
let x_axis = br - bl;
|
||||
let y_axis = tl - bl;
|
||||
[x_axis.x, x_axis.y, y_axis.x, y_axis.y, bl.x, bl.y]
|
||||
}
|
||||
|
||||
/// Anzahl der Bildkomponenten eines JPEG laut Frame-Header (1 = Graustufen, 3 = Farbe,
|
||||
/// 4 = CMYK), `None` bei unlesbarem Header.
|
||||
fn jpeg_components(bytes: &[u8]) -> Option<u8> {
|
||||
let mut i = 2;
|
||||
while i + 9 < bytes.len() {
|
||||
if bytes[i] != 0xFF {
|
||||
return None;
|
||||
}
|
||||
let marker = bytes[i + 1];
|
||||
let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
|
||||
// SOF0–SOF15 außer DHT (C4), JPG (C8) und DAC (CC)
|
||||
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
|
||||
return bytes.get(i + 9).copied();
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Bilddaten für das PDF: JPEG-Dateien werden unverändert eingebettet, alles andere als
|
||||
/// RGB auf weißem Grund (Transparenz wird mit dem Papier verrechnet), höchstens mit
|
||||
/// `PDF_IMAGE_DPI` bezogen auf die Größe auf der Seite.
|
||||
fn pdf_image_xobject(
|
||||
assets: &AssetStore,
|
||||
asset: &str,
|
||||
corners: &[glam::Vec2; 4],
|
||||
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
|
||||
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
|
||||
|
||||
let bytes = assets.read_bytes(asset)?;
|
||||
let decoded = image::load_from_memory(&bytes)?;
|
||||
|
||||
if asset.ends_with(".jpg") {
|
||||
let color_space = match jpeg_components(&bytes) {
|
||||
Some(1) => Some(ColorSpace::Greyscale),
|
||||
Some(3) => Some(ColorSpace::Rgb),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(color_space) = color_space {
|
||||
return Ok(ImageXObject {
|
||||
width: Px(decoded.width() as usize),
|
||||
height: Px(decoded.height() as usize),
|
||||
color_space,
|
||||
bits_per_component: ColorBits::Bit8,
|
||||
interpolate: true,
|
||||
image_data: bytes,
|
||||
image_filter: Some(ImageFilter::DCT),
|
||||
smask: None,
|
||||
clipping_bbox: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Größe auf der Seite in Zoll → benötigte Pixel
|
||||
let width_in = (corners[1] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
|
||||
let height_in = (corners[3] - corners[0]).length() / PAGE_WIDTH * A4_WIDTH_MM / 25.4;
|
||||
let max_w = ((width_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
|
||||
let max_h = ((height_in * PDF_IMAGE_DPI).ceil() as u32).max(1);
|
||||
let decoded = if decoded.width() > max_w || decoded.height() > max_h {
|
||||
decoded.resize(max_w, max_h, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
|
||||
let rgba = decoded.into_rgba8();
|
||||
let mut rgb = Vec::with_capacity((rgba.width() * rgba.height() * 3) as usize);
|
||||
for px in rgba.pixels() {
|
||||
let alpha = px[3] as u32;
|
||||
for c in &px.0[..3] {
|
||||
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
|
||||
}
|
||||
}
|
||||
Ok(ImageXObject {
|
||||
width: Px(rgba.width() as usize),
|
||||
height: Px(rgba.height() as usize),
|
||||
color_space: ColorSpace::Rgb,
|
||||
bits_per_component: ColorBits::Bit8,
|
||||
interpolate: true,
|
||||
image_data: rgb,
|
||||
image_filter: None,
|
||||
smask: None,
|
||||
clipping_bbox: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
|
||||
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
|
||||
fn render_image_layer(
|
||||
layer: &printpdf::PdfLayerReference,
|
||||
page: &Page,
|
||||
assets: Option<&AssetStore>,
|
||||
) {
|
||||
for stroke in &page.strokes {
|
||||
let (Some(asset), Some(corners)) = (stroke.image.as_deref(), stroke.image_corners()) else {
|
||||
continue;
|
||||
};
|
||||
let xobject = match assets.map(|a| pdf_image_xobject(a, asset, &corners)) {
|
||||
Some(Ok(xobject)) => xobject,
|
||||
other => {
|
||||
if let Some(Err(err)) = other {
|
||||
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
|
||||
}
|
||||
printpdf::ImageXObject {
|
||||
width: printpdf::Px(1),
|
||||
height: printpdf::Px(1),
|
||||
color_space: printpdf::ColorSpace::Rgb,
|
||||
bits_per_component: printpdf::ColorBits::Bit8,
|
||||
interpolate: false,
|
||||
image_data: vec![200, 200, 200],
|
||||
image_filter: None,
|
||||
smask: None,
|
||||
clipping_bbox: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
|
||||
layer.save_graphics_state();
|
||||
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(&corners)));
|
||||
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
|
||||
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
|
||||
printpdf::Image::from(xobject).add_to_layer(
|
||||
layer.clone(),
|
||||
printpdf::ImageTransform {
|
||||
dpi: Some(72.0),
|
||||
scale_x: Some(1.0 / width),
|
||||
scale_y: Some(1.0 / height),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
layer.restore_graphics_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
||||
/// als verlustfreien Vektorpfad im PDF.
|
||||
fn render_recognized_shape(
|
||||
@@ -270,6 +497,59 @@ fn render_recognized_shape(
|
||||
layer.add_polygon(poly);
|
||||
}
|
||||
|
||||
RecognizedShape::Ellipse {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
rotation,
|
||||
} => {
|
||||
let ring = (0..72)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
let p =
|
||||
crate::geometry::ellipse_point(*center, *radius_x, *radius_y, *rotation, t);
|
||||
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(x), Mm(y)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![ring],
|
||||
mode: PaintMode::Stroke,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
|
||||
RecognizedShape::Arrow { start, end, double } => {
|
||||
// Gleiche Spitzen wie auf dem Bildschirm; der Schaft endet an ihrer Basis
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let shaft_len = (*end - *start).length();
|
||||
let dir = (*end - *start).normalize_or_zero();
|
||||
let end_head = crate::geometry::arrow_head(*end, dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||
let start_head =
|
||||
crate::geometry::arrow_head(*start, -dir, PDF_SHAPE_HALF_WIDTH, shaft_len);
|
||||
let line_start = if *double { start_head.0 } else { *start };
|
||||
let to_pdf = |p: glam::Vec2| {
|
||||
let (x, y) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(x), Mm(y)), false)
|
||||
};
|
||||
layer.add_line(Line {
|
||||
points: vec![to_pdf(line_start), to_pdf(end_head.0)],
|
||||
is_closed: false,
|
||||
});
|
||||
layer.set_fill_color(stroke_color);
|
||||
let mut heads = vec![(*end, end_head)];
|
||||
if *double {
|
||||
heads.push((*start, start_head));
|
||||
}
|
||||
for (tip, (_, left, right)) in heads {
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![vec![to_pdf(tip), to_pdf(left), to_pdf(right)]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
RecognizedShape::Rectangle { corners } => {
|
||||
let mut ring = Vec::with_capacity(4);
|
||||
for c in corners {
|
||||
@@ -326,7 +606,12 @@ fn render_recognized_shape(
|
||||
layer.add_polygon(poly);
|
||||
}
|
||||
|
||||
RecognizedShape::Octahedron { center, radius_x, radius_y, depth } => {
|
||||
RecognizedShape::Octahedron {
|
||||
center,
|
||||
radius_x,
|
||||
radius_y,
|
||||
depth,
|
||||
} => {
|
||||
let edges = crate::geometry::octahedron_edges(*center, *radius_x, *radius_y, *depth);
|
||||
for (p1, p2) in edges {
|
||||
let (x1, y1) = page_to_pdf_mm(p1.x, p1.y);
|
||||
@@ -348,76 +633,58 @@ fn render_recognized_shape(
|
||||
y_axis_end,
|
||||
with_arrows,
|
||||
} => {
|
||||
// Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze
|
||||
// (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65)
|
||||
const PDF_SHAPE_HALF_WIDTH: f32 = 2.65;
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let y_arrow = crate::geometry::arrow_head(
|
||||
*y_axis_end,
|
||||
y_dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(*y_axis_end - *origin).length(),
|
||||
);
|
||||
let x_arrow = crate::geometry::arrow_head(
|
||||
*x_axis_end,
|
||||
x_dir,
|
||||
PDF_SHAPE_HALF_WIDTH,
|
||||
(*x_axis_end - *origin).length(),
|
||||
);
|
||||
let (y_line_end, x_line_end) = if *with_arrows {
|
||||
(y_arrow.0, x_arrow.0)
|
||||
} else {
|
||||
(*y_axis_end, *x_axis_end)
|
||||
};
|
||||
|
||||
let (ox, oy) = page_to_pdf_mm(origin.x, origin.y);
|
||||
let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
||||
let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
||||
|
||||
// Achse 1 (Y)
|
||||
let line_y = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(yx), Mm(yy)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
};
|
||||
layer.add_line(line_y);
|
||||
|
||||
// Achse 2 (X)
|
||||
let line_x = Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(xx), Mm(xy)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
};
|
||||
layer.add_line(line_x);
|
||||
for end in [y_line_end, x_line_end] {
|
||||
let (ex, ey) = page_to_pdf_mm(end.x, end.y);
|
||||
layer.add_line(Line {
|
||||
points: vec![
|
||||
(Point::new(Mm(ox), Mm(oy)), false),
|
||||
(Point::new(Mm(ex), Mm(ey)), false),
|
||||
],
|
||||
is_closed: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Pfeilspitzen als gefüllte Dreiecke
|
||||
if *with_arrows {
|
||||
layer.set_fill_color(stroke_color);
|
||||
|
||||
let y_dir = (*y_axis_end - *origin).normalize_or_zero();
|
||||
let arrow_len_page = 25.0;
|
||||
let arrow_w_page = 16.0;
|
||||
|
||||
let base_y = *y_axis_end - y_dir * arrow_len_page;
|
||||
let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
||||
let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5);
|
||||
|
||||
let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y);
|
||||
let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y);
|
||||
let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y);
|
||||
|
||||
let arrow_poly_y = Polygon {
|
||||
rings: vec![vec![
|
||||
(Point::new(Mm(tx), Mm(ty)), false),
|
||||
(Point::new(Mm(lx), Mm(ly)), false),
|
||||
(Point::new(Mm(rx), Mm(ry)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
layer.add_polygon(arrow_poly_y);
|
||||
|
||||
let x_dir = (*x_axis_end - *origin).normalize_or_zero();
|
||||
let base_x = *x_axis_end - x_dir * arrow_len_page;
|
||||
let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
||||
let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5);
|
||||
|
||||
let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y);
|
||||
let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y);
|
||||
let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y);
|
||||
|
||||
let arrow_poly_x = Polygon {
|
||||
rings: vec![vec![
|
||||
(Point::new(Mm(tx_x), Mm(ty_x)), false),
|
||||
(Point::new(Mm(lx_x), Mm(ly_x)), false),
|
||||
(Point::new(Mm(rx_x), Mm(ry_x)), false),
|
||||
]],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
};
|
||||
layer.add_polygon(arrow_poly_x);
|
||||
for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] {
|
||||
let ring = [tip, left, right]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (px, py) = page_to_pdf_mm(p.x, p.y);
|
||||
(Point::new(Mm(px), Mm(py)), false)
|
||||
})
|
||||
.collect();
|
||||
layer.add_polygon(Polygon {
|
||||
rings: vec![ring],
|
||||
mode: PaintMode::Fill,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,7 +781,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
|
||||
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
|
||||
|
||||
// Durchschnittliche Linienstärke aus dem Druck berechnen
|
||||
let avg_pressure = stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
|
||||
let avg_pressure =
|
||||
stroke.points.iter().map(|p| p.pressure).sum::<f32>() / stroke.points.len() as f32;
|
||||
let radius_mm = (avg_pressure * 0.75 + 0.25).max(0.25);
|
||||
let thickness_pt = radius_mm * 2.0 * (72.0 / 25.4); // mm in DTP-Punkte
|
||||
layer.set_outline_thickness(thickness_pt);
|
||||
@@ -693,11 +961,18 @@ mod tests {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let pdf_path = temp_dir.join("ink_notes_test_export.pdf");
|
||||
|
||||
let export_res = export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon);
|
||||
assert!(export_res.is_ok(), "PDF-Export zu Datei muss erfolgreich sein");
|
||||
let export_res =
|
||||
export_notebook_to_pdf(¬ebook, &pdf_path, ExportStrategy::FilledPolygon, None);
|
||||
assert!(
|
||||
export_res.is_ok(),
|
||||
"PDF-Export zu Datei muss erfolgreich sein"
|
||||
);
|
||||
|
||||
// Datei-Existenz und Größe prüfen
|
||||
assert!(pdf_path.exists(), "Die generierte PDF-Datei muss existieren");
|
||||
assert!(
|
||||
pdf_path.exists(),
|
||||
"Die generierte PDF-Datei muss existieren"
|
||||
);
|
||||
let metadata = std::fs::metadata(&pdf_path).unwrap();
|
||||
assert!(
|
||||
metadata.len() > 1024,
|
||||
@@ -713,7 +988,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// 3. Export als Bytes testen (Strategie B: BezierCurve)
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve);
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::BezierCurve, None);
|
||||
assert!(bytes_res.is_ok());
|
||||
let bytes = bytes_res.unwrap();
|
||||
assert!(bytes.len() > 1024);
|
||||
@@ -766,6 +1041,27 @@ mod tests {
|
||||
);
|
||||
page.add_stroke(coords_stroke);
|
||||
|
||||
let ellipse_stroke = Stroke::new(40, vec![], vec![], [0.1, 0.5, 0.5, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Ellipse {
|
||||
center: glam::Vec2::new(1400.0, 600.0),
|
||||
radius_x: 300.0,
|
||||
radius_y: 150.0,
|
||||
rotation: 0.4,
|
||||
},
|
||||
);
|
||||
page.add_stroke(ellipse_stroke);
|
||||
|
||||
for (id, double) in [(41, false), (42, true)] {
|
||||
let arrow_stroke = Stroke::new(id, vec![], vec![], [0.2, 0.2, 0.2, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Arrow {
|
||||
start: glam::Vec2::new(1200.0, 1800.0 + id as f32 * 40.0),
|
||||
end: glam::Vec2::new(1800.0, 1800.0 + id as f32 * 40.0),
|
||||
double,
|
||||
},
|
||||
);
|
||||
page.add_stroke(arrow_stroke);
|
||||
}
|
||||
|
||||
let tri_stroke = Stroke::new(5, vec![], vec![], [0.6, 0.2, 0.7, 1.0]).with_shape(
|
||||
crate::geometry::RecognizedShape::Triangle {
|
||||
corners: [
|
||||
@@ -815,10 +1111,159 @@ mod tests {
|
||||
);
|
||||
page.add_stroke(octahedron_stroke);
|
||||
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon);
|
||||
let bytes_res = export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None);
|
||||
assert!(bytes_res.is_ok(), "Shape export must succeed");
|
||||
let bytes = bytes_res.unwrap();
|
||||
assert!(bytes.len() > 1024);
|
||||
assert!(bytes.starts_with(b"%PDF-1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_matrix_maps_unit_square_to_corners() {
|
||||
// Bild über die volle Seitenbreite, oberes Viertel der Seite
|
||||
let corners = [
|
||||
glam::Vec2::new(0.0, 0.0),
|
||||
glam::Vec2::new(PAGE_WIDTH, 0.0),
|
||||
glam::Vec2::new(PAGE_WIDTH, PAGE_HEIGHT / 4.0),
|
||||
glam::Vec2::new(0.0, PAGE_HEIGHT / 4.0),
|
||||
];
|
||||
let [a, b, c, d, e, f] = image_matrix(&corners);
|
||||
let page_w_pt = A4_WIDTH_MM * 72.0 / 25.4;
|
||||
let page_h_pt = A4_HEIGHT_MM * 72.0 / 25.4;
|
||||
let map = |u: f32, v: f32| (a * u + c * v + e, b * u + d * v + f);
|
||||
let close = |(x, y): (f32, f32), (ex, ey): (f32, f32)| {
|
||||
(x - ex).abs() < 0.01 && (y - ey).abs() < 0.01
|
||||
};
|
||||
// Unten links des Bildes, oben rechts des Bildes (PDF-Ursprung unten links)
|
||||
assert!(close(map(0.0, 0.0), (0.0, page_h_pt * 0.75)));
|
||||
assert!(close(map(1.0, 1.0), (page_w_pt, page_h_pt)));
|
||||
// Gedrehtes Bild: Achsen folgen den Ecken
|
||||
let rotated = [
|
||||
glam::Vec2::new(500.0, 500.0),
|
||||
glam::Vec2::new(500.0, 900.0),
|
||||
glam::Vec2::new(300.0, 900.0),
|
||||
glam::Vec2::new(300.0, 500.0),
|
||||
];
|
||||
let m = image_matrix(&rotated);
|
||||
assert!(
|
||||
m[0].abs() < 1e-3 && m[1] < 0.0,
|
||||
"x-Achse zeigt nach unten: {m:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pdf_export_embeds_images() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let assets = AssetStore::new(dir.path());
|
||||
let jpg = crate::assets::tests::sample_image_bytes(64, 48, image::ImageFormat::Jpeg);
|
||||
let png = crate::assets::tests::sample_image_bytes(30, 20, image::ImageFormat::Png);
|
||||
let jpg_asset = assets.import_bytes(&jpg).unwrap().asset;
|
||||
let png_asset = assets.import_bytes(&png).unwrap().asset;
|
||||
|
||||
let mut notebook = Notebook::new();
|
||||
let page = notebook.current_page_mut();
|
||||
for (id, asset) in [
|
||||
(1, jpg_asset),
|
||||
(2, png_asset),
|
||||
(3, format!("{}.png", "0".repeat(64))),
|
||||
] {
|
||||
let y = id as f32 * 400.0;
|
||||
page.strokes.push(Stroke::new_image(
|
||||
id,
|
||||
asset,
|
||||
[
|
||||
glam::Vec2::new(100.0, y),
|
||||
glam::Vec2::new(700.0, y),
|
||||
glam::Vec2::new(700.0, y + 300.0),
|
||||
glam::Vec2::new(100.0, y + 300.0),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
let bytes =
|
||||
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets))
|
||||
.expect("Export mit Bildern");
|
||||
let contains = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
|
||||
assert!(
|
||||
contains(b"/DCTDecode"),
|
||||
"JPEG wird nicht unverändert eingebettet"
|
||||
);
|
||||
// JPEG-Daten liegen byte-identisch im PDF
|
||||
assert!(contains(&jpg[..64]));
|
||||
assert!(
|
||||
contains(b"/Images") || contains(b"(Images)"),
|
||||
"Bild-Ebene fehlt"
|
||||
);
|
||||
let image_objects = bytes
|
||||
.windows(b"/Subtype/Image".len())
|
||||
.filter(|w| *w == b"/Subtype/Image")
|
||||
.count()
|
||||
+ bytes
|
||||
.windows(b"/Subtype /Image".len())
|
||||
.filter(|w| *w == b"/Subtype /Image")
|
||||
.count();
|
||||
// JPEG, PNG und Ersatzfläche für die fehlende Datei
|
||||
assert_eq!(image_objects, 3);
|
||||
|
||||
// Ohne Asset-Ordner: Export klappt trotzdem (graue Flächen)
|
||||
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jpeg_component_count() {
|
||||
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
||||
assert_eq!(jpeg_components(&color), Some(3));
|
||||
let gray = {
|
||||
let img = image::GrayImage::from_pixel(8, 8, image::Luma([90]));
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::ImageLuma8(img)
|
||||
.write_to(
|
||||
&mut std::io::Cursor::new(&mut out),
|
||||
image::ImageFormat::Jpeg,
|
||||
)
|
||||
.unwrap();
|
||||
out
|
||||
};
|
||||
assert_eq!(jpeg_components(&gray), Some(1));
|
||||
assert_eq!(jpeg_components(b"\xFF\xD8kaputt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_component() {
|
||||
assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1");
|
||||
assert_eq!(
|
||||
sanitize_filename_component("Projekt/2026: Entwurf"),
|
||||
"Projekt_2026_ Entwurf"
|
||||
);
|
||||
assert_eq!(sanitize_filename_component(" "), "Notizbuch");
|
||||
assert_eq!(sanitize_filename_component(""), "Notizbuch");
|
||||
assert_eq!(
|
||||
sanitize_filename_component("Mathe * Vorlesung?"),
|
||||
"Mathe _ Vorlesung_"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_pdf_export_path_collision_avoidance() {
|
||||
let temp_dir = tempfile::tempdir().expect("Konnte tempdir nicht erstellen");
|
||||
let dir = temp_dir.path();
|
||||
|
||||
// 1. Erstmaliger Export: keine Kollision
|
||||
let path1 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||
assert_eq!(path1, dir.join("Mathe Vorlesung.pdf"));
|
||||
|
||||
// Datei anlegen
|
||||
std::fs::write(&path1, b"dummy pdf 1").expect("Konnte Datei nicht schreiben");
|
||||
|
||||
// 2. Zweiter Export: Kollision -> _1
|
||||
let path2 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||
assert_eq!(path2, dir.join("Mathe Vorlesung_1.pdf"));
|
||||
|
||||
// Zweite Datei anlegen
|
||||
std::fs::write(&path2, b"dummy pdf 2").expect("Konnte Datei nicht schreiben");
|
||||
|
||||
// 3. Dritter Export: Kollision -> _2
|
||||
let path3 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung");
|
||||
assert_eq!(path3, dir.join("Mathe Vorlesung_2.pdf"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder.
|
||||
//!
|
||||
//! 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 unterstützte Bilder des
|
||||
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::assets::is_supported_image_path;
|
||||
|
||||
/// Ein Eintrag der Dateiliste.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PickerEntry {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
/// Zustand der Dateiauswahl.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FilePicker {
|
||||
pub dir: PathBuf,
|
||||
pub entries: Vec<PickerEntry>,
|
||||
/// Ordner nicht lesbar
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl FilePicker {
|
||||
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner, sonst im Home-Ordner.
|
||||
pub fn open(dir: Option<&Path>) -> Self {
|
||||
let start = dir
|
||||
.filter(|d| d.is_dir())
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| shortcuts().into_iter().map(|(_, p)| p).find(|p| p.is_dir()))
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
let mut picker = Self {
|
||||
dir: start.clone(),
|
||||
entries: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
picker.navigate(&start);
|
||||
picker
|
||||
}
|
||||
|
||||
/// Wechselt in `dir` und liest den Inhalt neu ein.
|
||||
pub fn navigate(&mut self, dir: &Path) {
|
||||
match list_dir(dir) {
|
||||
Ok(entries) => {
|
||||
self.dir = dir.to_path_buf();
|
||||
self.entries = entries;
|
||||
self.error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(format!(
|
||||
"'{}' kann nicht geöffnet werden: {err}",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Ebene nach oben.
|
||||
pub fn go_up(&mut self) {
|
||||
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
|
||||
self.navigate(&parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordner und unterstützte Bilder in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
|
||||
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
|
||||
pub fn list_dir(dir: &Path) -> std::io::Result<Vec<PickerEntry>> {
|
||||
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
let path = entry.path();
|
||||
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
|
||||
let is_dir = path.is_dir();
|
||||
(is_dir || is_supported_image_path(&path)).then_some(PickerEntry { name, path, is_dir })
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_dir
|
||||
.cmp(&a.is_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Schnellzugriffe: Bilder, Downloads, Schreibtisch und Home-Ordner (soweit vorhanden),
|
||||
/// mit den Namen aus `~/.config/user-dirs.dirs` (z. B. „Bilder“ statt „Pictures“).
|
||||
pub fn shortcuts() -> Vec<(&'static str, PathBuf)> {
|
||||
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let user_dirs =
|
||||
std::fs::read_to_string(home.join(".config/user-dirs.dirs")).unwrap_or_default();
|
||||
let xdg = |key: &str, fallbacks: &[&str]| -> Option<PathBuf> {
|
||||
parse_user_dir(&user_dirs, key, &home)
|
||||
.into_iter()
|
||||
.chain(fallbacks.iter().map(|f| home.join(f)))
|
||||
.find(|p| p.is_dir() && *p != home)
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
if let Some(p) = xdg("XDG_PICTURES_DIR", &["Bilder", "Pictures"]) {
|
||||
list.push(("🖼 Bilder", p));
|
||||
}
|
||||
if let Some(p) = xdg("XDG_DOWNLOAD_DIR", &["Downloads"]) {
|
||||
list.push(("⬇ Downloads", p));
|
||||
}
|
||||
if let Some(p) = xdg("XDG_DESKTOP_DIR", &["Schreibtisch", "Desktop"]) {
|
||||
list.push(("🖥 Schreibtisch", p));
|
||||
}
|
||||
list.push(("🏠 Persönlicher Ordner", home));
|
||||
list
|
||||
}
|
||||
|
||||
/// Liest einen Eintrag wie `XDG_PICTURES_DIR="$HOME/Bilder"` aus `user-dirs.dirs`.
|
||||
pub fn parse_user_dir(content: &str, key: &str, home: &Path) -> Option<PathBuf> {
|
||||
content.lines().find_map(|line| {
|
||||
let value = line
|
||||
.trim()
|
||||
.strip_prefix(key)?
|
||||
.trim_start()
|
||||
.strip_prefix('=')?;
|
||||
let value = value.trim().trim_matches('"');
|
||||
let path = match value.strip_prefix("$HOME") {
|
||||
Some(rest) => home.join(rest.trim_start_matches('/')),
|
||||
None => PathBuf::from(value),
|
||||
};
|
||||
path.is_absolute().then_some(path)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_list_dir_sorts_and_filters() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["b.png", "A.jpg", "notiz.txt", ".versteckt.png", "c.webp"] {
|
||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
||||
}
|
||||
std::fs::create_dir(dir.path().join("zOrdner")).unwrap();
|
||||
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
|
||||
std::fs::create_dir(dir.path().join(".cache")).unwrap();
|
||||
|
||||
let names: Vec<String> = list_dir(dir.path())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigation_and_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("Fotos");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
|
||||
|
||||
let mut picker = FilePicker::open(Some(&sub));
|
||||
assert_eq!(picker.dir, sub);
|
||||
assert_eq!(picker.entries.len(), 1);
|
||||
|
||||
picker.go_up();
|
||||
assert_eq!(picker.dir, dir.path());
|
||||
assert!(picker.entries.iter().any(|e| e.is_dir && e.name == "Fotos"));
|
||||
|
||||
// Nicht vorhandener Ordner: Fehlermeldung, aktueller Ordner bleibt
|
||||
picker.navigate(&dir.path().join("gibt-es-nicht"));
|
||||
assert!(picker.error.is_some());
|
||||
assert_eq!(picker.dir, dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_user_dirs() {
|
||||
let home = Path::new("/home/anna");
|
||||
let content = "# Kommentar\nXDG_DESKTOP_DIR=\"$HOME/Schreibtisch\"\nXDG_PICTURES_DIR=\"$HOME/Bilder\"\nXDG_MUSIC_DIR=\"/daten/Musik\"\n";
|
||||
assert_eq!(
|
||||
parse_user_dir(content, "XDG_PICTURES_DIR", home),
|
||||
Some(PathBuf::from("/home/anna/Bilder"))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_user_dir(content, "XDG_MUSIC_DIR", home),
|
||||
Some(PathBuf::from("/daten/Musik"))
|
||||
);
|
||||
assert_eq!(parse_user_dir(content, "XDG_DOWNLOAD_DIR", home), None);
|
||||
}
|
||||
}
|
||||
+28
-7
@@ -265,9 +265,18 @@ mod tests {
|
||||
// 4. dt > 1.0 s (großer Zeitsprung z.B. auf t = 5.0 s): Muss reset() auslösen
|
||||
let (x4, y4, p4) = filter.filter(50.0, 100.0, 0.9, 5.0);
|
||||
// Nach dem Reset muss der Filter sofort exakt den neuen Punkt als Startpunkt annehmen
|
||||
assert_eq!(x4, 50.0, "Nach Reset muss der neue X-Wert übernommen werden");
|
||||
assert_eq!(y4, 100.0, "Nach Reset muss der neue Y-Wert übernommen werden");
|
||||
assert_eq!(p4, 0.9, "Nach Reset muss der neue Druckwert übernommen werden");
|
||||
assert_eq!(
|
||||
x4, 50.0,
|
||||
"Nach Reset muss der neue X-Wert übernommen werden"
|
||||
);
|
||||
assert_eq!(
|
||||
y4, 100.0,
|
||||
"Nach Reset muss der neue Y-Wert übernommen werden"
|
||||
);
|
||||
assert_eq!(
|
||||
p4, 0.9,
|
||||
"Nach Reset muss der neue Druckwert übernommen werden"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -344,9 +353,18 @@ mod tests {
|
||||
|
||||
// 2. Vergiftungsversuch mit NaN und Infinity
|
||||
let (x_nan, y_nan, p_nan) = filter.filter(f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.02);
|
||||
assert!(x_nan.is_finite(), "Filter darf kein NaN bei Vergiftung ausgeben");
|
||||
assert!(y_nan.is_finite(), "Filter darf kein Inf bei Vergiftung ausgeben");
|
||||
assert!(p_nan.is_finite(), "Filter darf kein -Inf bei Vergiftung ausgeben");
|
||||
assert!(
|
||||
x_nan.is_finite(),
|
||||
"Filter darf kein NaN bei Vergiftung ausgeben"
|
||||
);
|
||||
assert!(
|
||||
y_nan.is_finite(),
|
||||
"Filter darf kein Inf bei Vergiftung ausgeben"
|
||||
);
|
||||
assert!(
|
||||
p_nan.is_finite(),
|
||||
"Filter darf kein -Inf bei Vergiftung ausgeben"
|
||||
);
|
||||
|
||||
let (x_nan2, y_nan2, p_nan2) = filter.filter(12.0, 22.0, 0.6, f64::NAN);
|
||||
assert!(x_nan2.is_finite());
|
||||
@@ -364,6 +382,9 @@ mod tests {
|
||||
assert!(x3.is_finite());
|
||||
assert!(y3.is_finite());
|
||||
assert!(p3.is_finite());
|
||||
assert!(x3 > x2, "Filter muss sich nach Vergiftung weiter normal anpassen");
|
||||
assert!(
|
||||
x3 > x2,
|
||||
"Filter muss sich nach Vergiftung weiter normal anpassen"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1569
-201
File diff suppressed because it is too large.
Load diff
+8
-4
@@ -83,7 +83,8 @@ impl HistoryStack {
|
||||
if let Some(pos) = strokes.iter().position(|s| s.id == new.id) {
|
||||
strokes[pos] = old.clone();
|
||||
}
|
||||
self.redo_stack.push(HistoryAction::ReplaceStroke { old, new });
|
||||
self.redo_stack
|
||||
.push(HistoryAction::ReplaceStroke { old, new });
|
||||
}
|
||||
HistoryAction::ModifyStrokes { before, after } => {
|
||||
for b in &before {
|
||||
@@ -91,7 +92,8 @@ impl HistoryStack {
|
||||
strokes[pos] = b.clone();
|
||||
}
|
||||
}
|
||||
self.redo_stack.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.redo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +128,8 @@ impl HistoryStack {
|
||||
if let Some(pos) = strokes.iter().position(|s| s.id == old.id) {
|
||||
strokes[pos] = new.clone();
|
||||
}
|
||||
self.undo_stack.push(HistoryAction::ReplaceStroke { old, new });
|
||||
self.undo_stack
|
||||
.push(HistoryAction::ReplaceStroke { old, new });
|
||||
}
|
||||
HistoryAction::ModifyStrokes { before, after } => {
|
||||
for a in &after {
|
||||
@@ -134,7 +137,8 @@ impl HistoryStack {
|
||||
strokes[pos] = a.clone();
|
||||
}
|
||||
}
|
||||
self.undo_stack.push(HistoryAction::ModifyStrokes { before, after });
|
||||
self.undo_stack
|
||||
.push(HistoryAction::ModifyStrokes { before, after });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
|
||||
//! Papier und vor der Tinte.
|
||||
//!
|
||||
//! Texturen werden beim ersten Bedarf aus dem Asset-Ordner geladen (verkleinert auf
|
||||
//! `assets::MAX_DISPLAY_DIMENSION`) und mit Mipmaps versehen, damit verkleinerte Bilder
|
||||
//! beim Herauszoomen nicht flimmern.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::assets::{AssetStore, RgbaImage, MAX_DISPLAY_DIMENSION};
|
||||
|
||||
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct ImageVertex {
|
||||
pub position: [f32; 2],
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
impl ImageVertex {
|
||||
const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
|
||||
wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2];
|
||||
|
||||
fn desc() -> wgpu::VertexBufferLayout<'static> {
|
||||
wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<ImageVertex>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &Self::ATTRIBUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwei Dreiecke für ein Bild mit den Ecken oben links, oben rechts, unten rechts, unten links.
|
||||
pub fn quad_vertices(corners: &[glam::Vec2; 4]) -> [ImageVertex; 6] {
|
||||
let v = |i: usize, uv: [f32; 2]| ImageVertex {
|
||||
position: corners[i].to_array(),
|
||||
uv,
|
||||
};
|
||||
let (tl, tr, br, bl) = (
|
||||
v(0, [0.0, 0.0]),
|
||||
v(1, [1.0, 0.0]),
|
||||
v(2, [1.0, 1.0]),
|
||||
v(3, [0.0, 1.0]),
|
||||
);
|
||||
[tl, tr, bl, bl, tr, br]
|
||||
}
|
||||
|
||||
/// Mipmap-Stufen eines Bildes, jeweils halbe Kantenlänge bis 1 × 1.
|
||||
pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
||||
let mut levels = vec![base.clone()];
|
||||
let Some(mut current) =
|
||||
image::RgbaImage::from_raw(base.width, base.height, base.pixels.clone())
|
||||
else {
|
||||
return levels;
|
||||
};
|
||||
while current.width() > 1 || current.height() > 1 {
|
||||
let (w, h) = ((current.width() / 2).max(1), (current.height() / 2).max(1));
|
||||
current = image::imageops::resize(¤t, w, h, image::imageops::FilterType::Triangle);
|
||||
levels.push(RgbaImage {
|
||||
width: w,
|
||||
height: h,
|
||||
pixels: current.as_raw().clone(),
|
||||
});
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||
const MISSING: &str = "";
|
||||
/// So viele Texturen bleiben höchstens im Speicher, auch wenn sie auf der aktuellen Seite
|
||||
/// nicht mehr gebraucht werden (schnelles Zurückblättern).
|
||||
const TEXTURE_CACHE_LIMIT: usize = 24;
|
||||
|
||||
pub struct ImageLayer {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
texture_layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
textures: HashMap<String, wgpu::BindGroup>,
|
||||
/// Bilder der aktuellen Seite: Asset-Name und Ecken
|
||||
quads: Vec<(String, [glam::Vec2; 4])>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
needs_upload: bool,
|
||||
}
|
||||
|
||||
impl ImageLayer {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shader: &wgpu::ShaderModule,
|
||||
camera_layout: &wgpu::BindGroupLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Image Texture Bind Group Layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Image Pipeline Layout"),
|
||||
bind_group_layouts: &[camera_layout, &texture_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Image Render Pipeline"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: "vs_image",
|
||||
buffers: &[ImageVertex::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: "fs_image",
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Image Sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
texture_layout,
|
||||
sampler,
|
||||
textures: HashMap::new(),
|
||||
quads: Vec::new(),
|
||||
vertex_buffer: None,
|
||||
needs_upload: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt die Bilder der aktuellen Seite (Asset-Name und Ecken in Seitenkoordinaten).
|
||||
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
|
||||
if quads != self.quads {
|
||||
self.quads = quads;
|
||||
self.needs_upload = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||
/// ohne Änderungen kostet das nichts.
|
||||
pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, assets: &AssetStore) {
|
||||
if !self.needs_upload {
|
||||
return;
|
||||
}
|
||||
self.needs_upload = false;
|
||||
|
||||
let missing: Vec<String> = self
|
||||
.quads
|
||||
.iter()
|
||||
.map(|(asset, _)| asset)
|
||||
.filter(|asset| !self.textures.contains_key(*asset))
|
||||
.cloned()
|
||||
.collect();
|
||||
for asset in missing {
|
||||
if self.textures.contains_key(&asset) {
|
||||
continue;
|
||||
}
|
||||
let bind_group = match assets.load_rgba(&asset, MAX_DISPLAY_DIMENSION) {
|
||||
Ok(image) => self.upload_texture(device, queue, &asset, &image),
|
||||
Err(err) => {
|
||||
eprintln!("[Bilder] '{asset}' kann nicht angezeigt werden: {err}");
|
||||
self.missing_texture(device, queue)
|
||||
}
|
||||
};
|
||||
self.textures.insert(asset, bind_group);
|
||||
}
|
||||
|
||||
// Nicht mehr benötigte Texturen freigeben, wenn der Speicher voll wird
|
||||
if self.textures.len() > TEXTURE_CACHE_LIMIT {
|
||||
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
|
||||
self.textures.retain(|name, _| used.contains(name));
|
||||
}
|
||||
|
||||
let vertices: Vec<ImageVertex> = self
|
||||
.quads
|
||||
.iter()
|
||||
.flat_map(|(_, corners)| quad_vertices(corners))
|
||||
.collect();
|
||||
if vertices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
|
||||
let too_small = self
|
||||
.vertex_buffer
|
||||
.as_ref()
|
||||
.map_or(true, |b| b.size() < bytes.len() as u64);
|
||||
if too_small {
|
||||
self.vertex_buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Image Vertex Buffer"),
|
||||
size: (bytes.len() as u64 * 2).max(1024),
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}));
|
||||
}
|
||||
if let Some(buffer) = &self.vertex_buffer {
|
||||
queue.write_buffer(buffer, 0, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
|
||||
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
|
||||
let Some(buffer) = &self.vertex_buffer else {
|
||||
return;
|
||||
};
|
||||
if self.quads.is_empty() || self.needs_upload {
|
||||
return;
|
||||
}
|
||||
render_pass.set_pipeline(&self.pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for (i, (asset, _)) in self.quads.iter().enumerate() {
|
||||
let Some(bind_group) = self.textures.get(asset) else {
|
||||
continue;
|
||||
};
|
||||
render_pass.set_bind_group(1, bind_group, &[]);
|
||||
let start = (i * 6) as u32;
|
||||
render_pass.draw(start..start + 6, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_texture(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
|
||||
// Hellgraue Fläche statt eines unsichtbaren Bildes
|
||||
let gray = RgbaImage {
|
||||
width: 1,
|
||||
height: 1,
|
||||
pixels: vec![200, 200, 200, 255],
|
||||
};
|
||||
self.upload_texture(device, queue, MISSING, &gray)
|
||||
}
|
||||
|
||||
fn upload_texture(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &RgbaImage,
|
||||
) -> wgpu::BindGroup {
|
||||
let levels = mip_chain(image);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels.len() as u32,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
for (level, mip) in levels.iter().enumerate() {
|
||||
queue.write_texture(
|
||||
wgpu::ImageCopyTexture {
|
||||
texture: &texture,
|
||||
mip_level: level as u32,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&mip.pixels,
|
||||
wgpu::ImageDataLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(4 * mip.width),
|
||||
rows_per_image: Some(mip.height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: mip.width,
|
||||
height: mip.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Image Texture Bind Group"),
|
||||
layout: &self.texture_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_quad_vertices_map_corners_to_texture_corners() {
|
||||
let corners = [
|
||||
glam::Vec2::new(10.0, 10.0),
|
||||
glam::Vec2::new(50.0, 10.0),
|
||||
glam::Vec2::new(50.0, 30.0),
|
||||
glam::Vec2::new(10.0, 30.0),
|
||||
];
|
||||
let quad = quad_vertices(&corners);
|
||||
for v in quad {
|
||||
// Texturkoordinate entspricht der relativen Lage im Rechteck
|
||||
let u = (v.position[0] - 10.0) / 40.0;
|
||||
let w = (v.position[1] - 10.0) / 20.0;
|
||||
assert_eq!(v.uv, [u, w]);
|
||||
}
|
||||
// Beide Dreiecke decken alle vier Ecken ab
|
||||
for c in corners {
|
||||
assert!(quad.iter().any(|v| v.position == c.to_array()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mip_chain_halves_down_to_one_pixel() {
|
||||
let base = RgbaImage {
|
||||
width: 8,
|
||||
height: 3,
|
||||
pixels: vec![128; 8 * 3 * 4],
|
||||
};
|
||||
let levels = mip_chain(&base);
|
||||
let sizes: Vec<(u32, u32)> = levels.iter().map(|l| (l.width, l.height)).collect();
|
||||
assert_eq!(sizes, vec![(8, 3), (4, 1), (2, 1), (1, 1)]);
|
||||
for l in &levels {
|
||||
assert_eq!(l.pixels.len(), (l.width * l.height * 4) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
//! ink-notes: Latenzoptimierte Inking-App für Linux.
|
||||
|
||||
pub mod assets;
|
||||
pub mod autosave;
|
||||
pub mod background;
|
||||
pub mod canvas;
|
||||
pub mod document;
|
||||
pub mod export;
|
||||
pub mod file_picker;
|
||||
pub mod filter;
|
||||
pub mod geometry;
|
||||
pub mod history;
|
||||
pub mod image_layer;
|
||||
pub mod platform;
|
||||
pub mod selection;
|
||||
pub mod settings;
|
||||
pub mod spline;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
|
||||
+1419
-398
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,96 @@
|
||||
//! Erkennung der Laufzeitumgebung und Wahl des Anzeigesystems (X11 oder Wayland).
|
||||
//!
|
||||
//! Unter WSL (WSLg) trennt der Wayland-Compositor die Verbindung, sobald die App ein Bild
|
||||
//! über Vulkan übergibt ("Connection reset by peer"). Über X11 (Xwayland) läuft sie stabil,
|
||||
//! daher wird unter WSL standardmäßig X11 verwendet. `INK_NOTES_DISPLAY=x11|wayland`
|
||||
//! übersteuert die Wahl.
|
||||
|
||||
/// Anzeigesystem, mit dem die Ereignisschleife erzeugt wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplayBackend {
|
||||
/// Automatische Wahl von winit (Wayland, falls verfügbar, sonst X11)
|
||||
Automatic,
|
||||
X11,
|
||||
Wayland,
|
||||
}
|
||||
|
||||
/// Wählt das Anzeigesystem.
|
||||
///
|
||||
/// - `override_value`: Inhalt von `INK_NOTES_DISPLAY` (`x11` oder `wayland`, sonst ignoriert)
|
||||
/// - `is_wsl`: läuft unter WSL
|
||||
/// - `has_x11_display`: `DISPLAY` ist gesetzt (X11/Xwayland erreichbar)
|
||||
pub fn choose_display_backend(
|
||||
override_value: Option<&str>,
|
||||
is_wsl: bool,
|
||||
has_x11_display: bool,
|
||||
) -> DisplayBackend {
|
||||
match override_value
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("x11") => return DisplayBackend::X11,
|
||||
Some("wayland") => return DisplayBackend::Wayland,
|
||||
_ => {}
|
||||
}
|
||||
if is_wsl && has_x11_display {
|
||||
DisplayBackend::X11
|
||||
} else {
|
||||
DisplayBackend::Automatic
|
||||
}
|
||||
}
|
||||
|
||||
/// Läuft das Programm unter WSL (Windows-Subsystem für Linux)?
|
||||
pub fn is_wsl() -> bool {
|
||||
if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
|
||||
return true;
|
||||
}
|
||||
std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Wahl anhand der aktuellen Umgebung.
|
||||
pub fn display_backend_from_env() -> DisplayBackend {
|
||||
choose_display_backend(
|
||||
std::env::var("INK_NOTES_DISPLAY").ok().as_deref(),
|
||||
is_wsl(),
|
||||
std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_choose_display_backend() {
|
||||
// WSL mit X11-Display: X11 (Wayland unter WSLg bricht ab)
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
// WSL ohne X11-Display: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, true, false),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Natives Linux: winit entscheiden lassen
|
||||
assert_eq!(
|
||||
choose_display_backend(None, false, true),
|
||||
DisplayBackend::Automatic
|
||||
);
|
||||
// Übersteuerung
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("wayland"), true, true),
|
||||
DisplayBackend::Wayland
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some(" X11 "), false, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
assert_eq!(
|
||||
choose_display_backend(Some("quatsch"), true, true),
|
||||
DisplayBackend::X11
|
||||
);
|
||||
}
|
||||
}
|
||||
+90
-26
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit.
|
||||
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
use glam::Vec2;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::document::{Aabb, Page, Stroke};
|
||||
@@ -115,12 +115,15 @@ impl SelectionState {
|
||||
Page::selection_aabb(&strokes)
|
||||
}
|
||||
|
||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||
pub fn hit_test_handles(
|
||||
&self,
|
||||
page: &Page,
|
||||
canvas: &CanvasState,
|
||||
screen_pos: Vec2,
|
||||
pixels_per_point: f32,
|
||||
) -> SelectionHandleHit {
|
||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||
return SelectionHandleHit::None;
|
||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
||||
let screen_center = (screen_min + screen_max) * 0.5;
|
||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||
|
||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
||||
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||
let ppp = pixels_per_point;
|
||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||
return SelectionHandleHit::RotateHandle {
|
||||
center_page: page_center,
|
||||
};
|
||||
@@ -144,16 +148,16 @@ impl SelectionState {
|
||||
|
||||
// 2. 4 Eck-Skalierungsgriffe testen
|
||||
let corners_page = [
|
||||
aabb.min, // 0: oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
|
||||
aabb.max, // 2: unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
|
||||
aabb.min, // 0: oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts
|
||||
aabb.max, // 2: unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links
|
||||
];
|
||||
let fixed_corners_page = [
|
||||
aabb.max, // gegenüber 0 -> unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
|
||||
aabb.min, // gegenüber 2 -> oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
|
||||
aabb.max, // gegenüber 0 -> unten-rechts
|
||||
Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links
|
||||
aabb.min, // gegenüber 2 -> oben-links
|
||||
Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts
|
||||
];
|
||||
|
||||
let corners_screen = [
|
||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
||||
];
|
||||
|
||||
for i in 0..4 {
|
||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
||||
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||
return SelectionHandleHit::CornerHandle {
|
||||
corner_idx: i,
|
||||
fixed_corner_page: fixed_corners_page[i],
|
||||
@@ -239,7 +243,12 @@ impl SelectionState {
|
||||
if self.selected_ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut strokes = page.get_strokes(&self.selected_ids);
|
||||
// Bilder behalten ihre Farben
|
||||
let mut strokes: Vec<_> = page
|
||||
.get_strokes(&self.selected_ids)
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_image())
|
||||
.collect();
|
||||
if strokes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -273,6 +282,16 @@ impl SelectionState {
|
||||
}
|
||||
|
||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||
|
||||
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||
}
|
||||
|
||||
pub fn render_selection_overlay(
|
||||
ctx: &egui::Context,
|
||||
selection: &SelectionState,
|
||||
@@ -283,11 +302,17 @@ pub fn render_selection_overlay(
|
||||
egui::Order::Foreground,
|
||||
egui::Id::new("selection_overlay_layer"),
|
||||
));
|
||||
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||
let ppp = ctx.pixels_per_point();
|
||||
|
||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||
if let DragMode::BoxSelect { start_page, current_page } = selection.drag_mode {
|
||||
let p1 = canvas.page_to_screen(start_page);
|
||||
let p2 = canvas.page_to_screen(current_page);
|
||||
if let DragMode::BoxSelect {
|
||||
start_page,
|
||||
current_page,
|
||||
} = selection.drag_mode
|
||||
{
|
||||
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
|
||||
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
|
||||
let min_x = p1[0].min(p2[0]);
|
||||
let min_y = p1[1].min(p2[1]);
|
||||
let max_x = p1[0].max(p2[0]);
|
||||
@@ -299,12 +324,15 @@ pub fn render_selection_overlay(
|
||||
painter.rect_filled(
|
||||
rect,
|
||||
egui::Rounding::same(4.0_f32),
|
||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
||||
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
egui::Rounding::same(4.0_f32),
|
||||
egui::Stroke::new(1.5_f32, egui::Color32::from_rgba_premultiplied(80, 160, 255, 220)),
|
||||
egui::Stroke::new(
|
||||
1.5_f32,
|
||||
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -314,8 +342,8 @@ pub fn render_selection_overlay(
|
||||
return;
|
||||
};
|
||||
|
||||
let p_min = canvas.page_to_screen(aabb.min);
|
||||
let p_max = canvas.page_to_screen(aabb.max);
|
||||
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||
|
||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||
@@ -331,7 +359,7 @@ pub fn render_selection_overlay(
|
||||
|
||||
// Dreh-Stängel (Stalk) und Drehgriff
|
||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||
|
||||
painter.line_segment(
|
||||
[top_center, rot_handle],
|
||||
@@ -357,7 +385,11 @@ pub fn render_selection_overlay(
|
||||
|
||||
for c in corners {
|
||||
let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size));
|
||||
painter.rect_filled(handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE);
|
||||
painter.rect_filled(
|
||||
handle_rect,
|
||||
egui::Rounding::same(1.5_f32),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
painter.rect_stroke(
|
||||
handle_rect,
|
||||
egui::Rounding::same(1.5_f32),
|
||||
@@ -369,7 +401,11 @@ pub fn render_selection_overlay(
|
||||
let badge_text = format!(
|
||||
"{} {} | [R] Drehen [Entf] Löschen",
|
||||
selection.count(),
|
||||
if selection.count() == 1 { "Objekt" } else { "Objekte" }
|
||||
if selection.count() == 1 {
|
||||
"Objekt"
|
||||
} else {
|
||||
"Objekte"
|
||||
}
|
||||
);
|
||||
let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0);
|
||||
painter.text(
|
||||
@@ -459,11 +495,39 @@ mod tests {
|
||||
let orig_pos = page.strokes[0].points[0].position;
|
||||
assert!(sel.rotate_selected_90_deg(&mut page));
|
||||
let rotated_pos = page.strokes[0].points[0].position;
|
||||
assert!((orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1);
|
||||
assert!(
|
||||
(orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1
|
||||
);
|
||||
|
||||
// Undo rotate
|
||||
assert!(page.undo());
|
||||
let restored_pos = page.strokes[0].points[0].position;
|
||||
assert!((orig_pos.x - restored_pos.x).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recolor_ignores_images() {
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
let corners = [
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(100.0, 0.0),
|
||||
Vec2::new(100.0, 50.0),
|
||||
Vec2::new(0.0, 50.0),
|
||||
];
|
||||
page.add_stroke(Stroke::new_image(1, "a.png".into(), corners));
|
||||
let undo_depth = page.history.undo_stack.len();
|
||||
|
||||
let mut sel = SelectionState::new();
|
||||
sel.select_single(1);
|
||||
// Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag
|
||||
assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(page.history.undo_stack.len(), undo_depth);
|
||||
|
||||
// Bild und Strich: nur der Strich wird umgefärbt
|
||||
page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0]));
|
||||
sel.select_multiple(vec![1, 2]);
|
||||
assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
|
||||
//!
|
||||
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::background::PaperTheme;
|
||||
use crate::storage::{self, StorageError};
|
||||
|
||||
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum SidebarSide {
|
||||
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
|
||||
#[default]
|
||||
Left,
|
||||
/// Rechts (für Linkshänder)
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Druckkennlinie des Stifts: passt die sehr unterschiedlichen Druckbereiche verschiedener
|
||||
/// Digitizer (Wacom EMR, MPP, …) an das Schreibgefühl an.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PressureCurve {
|
||||
/// Exponent der Kennlinie: < 1 weich (schon leichter Druck ergibt kräftige Linien),
|
||||
/// > 1 hart (erst fester Druck ergibt kräftige Linien)
|
||||
pub exponent: f32,
|
||||
/// Rohdruck unterhalb dieser Schwelle zählt als kein Druck (dünnste Linie);
|
||||
/// unterdrückt das Grundrauschen empfindlicher Stifte
|
||||
pub threshold: f32,
|
||||
}
|
||||
|
||||
impl PressureCurve {
|
||||
pub const EXPONENT_RANGE: std::ops::RangeInclusive<f32> = 0.3..=3.0;
|
||||
pub const THRESHOLD_RANGE: std::ops::RangeInclusive<f32> = 0.0..=0.5;
|
||||
|
||||
/// Rechnet den Rohdruck des Stifts (0–1) in den Druck für die Strichbreite um.
|
||||
pub fn apply(&self, raw: f32) -> f32 {
|
||||
let curve = self.sanitized();
|
||||
if !raw.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let normalized = ((raw - curve.threshold) / (1.0 - curve.threshold)).clamp(0.0, 1.0);
|
||||
normalized.powf(curve.exponent)
|
||||
}
|
||||
|
||||
/// Begrenzt Werte aus einer (evtl. von Hand bearbeiteten) Einstellungsdatei auf den
|
||||
/// gültigen Bereich.
|
||||
pub fn sanitized(&self) -> Self {
|
||||
let default = Self::default();
|
||||
let clamp = |v: f32, range: &std::ops::RangeInclusive<f32>, fallback: f32| {
|
||||
if v.is_finite() {
|
||||
v.clamp(*range.start(), *range.end())
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
};
|
||||
Self {
|
||||
exponent: clamp(self.exponent, &Self::EXPONENT_RANGE, default.exponent),
|
||||
threshold: clamp(self.threshold, &Self::THRESHOLD_RANGE, default.threshold),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PressureCurve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
exponent: 1.0,
|
||||
threshold: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Benutzereinstellungen der Oberfläche.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiSettings {
|
||||
pub sidebar_side: SidebarSide,
|
||||
/// Papierfarbe der Anzeige
|
||||
pub paper_theme: PaperTheme,
|
||||
/// Dunkle Stiftfarben auf dunklem Papier aufhellen
|
||||
pub adapt_ink_colors: bool,
|
||||
/// Druckkennlinie des Stifts
|
||||
pub pressure_curve: PressureCurve,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sidebar_side: SidebarSide::default(),
|
||||
paper_theme: PaperTheme::default(),
|
||||
adapt_ink_colors: true,
|
||||
pressure_curve: PressureCurve::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiSettings {
|
||||
/// Standardpfad gemäß XDG-Spezifikation.
|
||||
pub fn default_path() -> Option<PathBuf> {
|
||||
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||
};
|
||||
Some(base.join("ink-notes").join("ui.json"))
|
||||
}
|
||||
|
||||
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||
pub fn load_from(path: &Path) -> Self {
|
||||
let mut settings: Self = match std::fs::read_to_string(path) {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||
path.display()
|
||||
);
|
||||
Self::default()
|
||||
}),
|
||||
Err(_) => Self::default(),
|
||||
};
|
||||
settings.pressure_curve = settings.pressure_curve.sanitized();
|
||||
settings
|
||||
}
|
||||
|
||||
/// Speichert die Einstellungen atomar.
|
||||
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||
storage::atomic_write_json(path, self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_settings_round_trip_and_fallbacks() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ink-notes").join("ui.json");
|
||||
|
||||
// Fehlende Datei -> Standard (links)
|
||||
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
|
||||
|
||||
let settings = UiSettings {
|
||||
sidebar_side: SidebarSide::Right,
|
||||
paper_theme: PaperTheme::Black,
|
||||
adapt_ink_colors: false,
|
||||
pressure_curve: PressureCurve {
|
||||
exponent: 1.8,
|
||||
threshold: 0.1,
|
||||
},
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
|
||||
// Beschädigte Datei -> Standard statt Absturz
|
||||
std::fs::write(&path, b"{ kaputt").unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
|
||||
// Unbekannte / fehlende Felder werden toleriert
|
||||
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_settings_file_keeps_new_defaults() {
|
||||
// ui.json aus v0.3.x kennt nur die Seite der Werkzeugleiste
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ui.json");
|
||||
std::fs::write(&path, br#"{"sidebar_side": "Right"}"#).unwrap();
|
||||
let settings = UiSettings::load_from(&path);
|
||||
assert_eq!(settings.sidebar_side, SidebarSide::Right);
|
||||
assert_eq!(settings.paper_theme, PaperTheme::White);
|
||||
assert!(settings.adapt_ink_colors);
|
||||
assert_eq!(settings.pressure_curve, PressureCurve::default());
|
||||
|
||||
// Unsinnige Werte aus einer von Hand bearbeiteten Datei werden begrenzt
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"pressure_curve": {"exponent": 50.0, "threshold": -3.0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let curve = UiSettings::load_from(&path).pressure_curve;
|
||||
assert_eq!(curve.exponent, 3.0);
|
||||
assert_eq!(curve.threshold, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pressure_curve() {
|
||||
// Standard: unverändert
|
||||
let linear = PressureCurve::default();
|
||||
for p in [0.0, 0.25, 0.5, 1.0] {
|
||||
assert!((linear.apply(p) - p).abs() < 1e-6);
|
||||
}
|
||||
// Weich: halber Druck ergibt mehr als halbe Breite, hart: weniger
|
||||
let soft = PressureCurve {
|
||||
exponent: 0.5,
|
||||
threshold: 0.0,
|
||||
};
|
||||
let hard = PressureCurve {
|
||||
exponent: 2.0,
|
||||
threshold: 0.0,
|
||||
};
|
||||
assert!(soft.apply(0.5) > 0.6);
|
||||
assert!(hard.apply(0.5) < 0.3);
|
||||
// Endpunkte bleiben fest
|
||||
for curve in [soft, hard] {
|
||||
assert_eq!(curve.apply(0.0), 0.0);
|
||||
assert!((curve.apply(1.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
// Schwelle: darunter kein Druck, darüber auf den vollen Bereich gestreckt
|
||||
let gated = PressureCurve {
|
||||
exponent: 1.0,
|
||||
threshold: 0.2,
|
||||
};
|
||||
assert_eq!(gated.apply(0.15), 0.0);
|
||||
assert!((gated.apply(0.6) - 0.5).abs() < 1e-6);
|
||||
assert!((gated.apply(1.0) - 1.0).abs() < 1e-6);
|
||||
// Monoton steigend
|
||||
let mut last = -1.0;
|
||||
for i in 0..=100 {
|
||||
let v = hard.apply(i as f32 / 100.0);
|
||||
assert!(v >= last);
|
||||
last = v;
|
||||
}
|
||||
// Ungültige Eingaben ergeben nie NaN
|
||||
assert_eq!(linear.apply(f32::NAN), 0.0);
|
||||
assert_eq!(linear.apply(7.0), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ struct VertexOutput {
|
||||
struct CameraUniforms {
|
||||
transform_scale: vec2<f32>,
|
||||
transform_offset: vec2<f32>,
|
||||
// x: 1.0 = dunkle Tinte für dunkles Papier aufhellen (siehe `adapt_ink_color`)
|
||||
ink_params: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0)
|
||||
@@ -32,3 +34,46 @@ fn vs_main(in: VertexInput) -> VertexOutput {
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
}
|
||||
|
||||
// Striche: bei dunklem Papier wird die HSL-Helligkeit dunkler Farben gespiegelt
|
||||
// (L -> 1 - L, Farbton und Sättigung bleiben). Entspricht `background::adapt_ink_color`.
|
||||
@fragment
|
||||
fn fs_ink(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if (camera.ink_params.x < 0.5) {
|
||||
return in.color;
|
||||
}
|
||||
let max_c = max(max(in.color.r, in.color.g), in.color.b);
|
||||
let min_c = min(min(in.color.r, in.color.g), in.color.b);
|
||||
let lift = max(1.0 - max_c - min_c, 0.0);
|
||||
return vec4<f32>(in.color.rgb + vec3<f32>(lift), in.color.a);
|
||||
}
|
||||
|
||||
// Eingefügte Bilder: texturierte Vierecke unter der Tinte
|
||||
struct ImageVertexInput {
|
||||
@location(0) position: vec2<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
struct ImageVertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@group(1) @binding(0)
|
||||
var image_texture: texture_2d<f32>;
|
||||
@group(1) @binding(1)
|
||||
var image_sampler: sampler;
|
||||
|
||||
@vertex
|
||||
fn vs_image(in: ImageVertexInput) -> ImageVertexOutput {
|
||||
var out: ImageVertexOutput;
|
||||
let ndc = in.position * camera.transform_scale + camera.transform_offset;
|
||||
out.clip_position = vec4<f32>(ndc, 0.0, 1.0);
|
||||
out.uv = in.uv;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_image(in: ImageVertexOutput) -> @location(0) vec4<f32> {
|
||||
return textureSample(image_texture, image_sampler, in.uv);
|
||||
}
|
||||
+50
-16
@@ -105,6 +105,18 @@ impl Default for SplineConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl SplineConfig {
|
||||
/// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm).
|
||||
pub fn from_width_units(width_units: f32) -> Self {
|
||||
let base_radius = (width_units / 2.0).max(0.25);
|
||||
Self {
|
||||
min_radius: (base_radius * 0.5).max(0.15),
|
||||
max_radius: (base_radius * 1.5).max(0.35),
|
||||
samples_per_segment: 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r).
|
||||
pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 {
|
||||
let p = pressure.clamp(0.0, 1.0);
|
||||
@@ -355,11 +367,11 @@ fn rdp_mark(positions: &[glam::Vec2], start: usize, end: usize, epsilon: f32, ke
|
||||
let mut max_dist = 0.0_f32;
|
||||
let mut max_idx = start;
|
||||
|
||||
for i in (start + 1)..end {
|
||||
let dist = point_to_segment_distance(positions[i], a, b);
|
||||
for (offset, &pos) in positions[(start + 1)..end].iter().enumerate() {
|
||||
let dist = point_to_segment_distance(pos, a, b);
|
||||
if dist > max_dist {
|
||||
max_dist = dist;
|
||||
max_idx = i;
|
||||
max_idx = start + 1 + offset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,10 +430,7 @@ pub fn detect_corners(samples: &[StylusSample], threshold_deg: f32) -> Vec<usize
|
||||
}
|
||||
|
||||
/// Tastet einen zusammenhängenden, glatten Teilabschnitt (Run) zwischen Ecken ab.
|
||||
fn sample_smooth_run(
|
||||
control_points: &[ControlPoint],
|
||||
config: &SplineConfig,
|
||||
) -> Vec<SplineSample> {
|
||||
fn sample_smooth_run(control_points: &[ControlPoint], config: &SplineConfig) -> Vec<SplineSample> {
|
||||
match control_points.len() {
|
||||
0 => Vec::new(),
|
||||
1 => {
|
||||
@@ -621,7 +630,12 @@ pub fn generate_finalized_triangle_strip(
|
||||
/// 3. Ramer-Douglas-Peucker (RDP) Dezimierung (Toleranz ca. 0.05 bis 0.1 mm)
|
||||
/// 4. Generierung der finalen Triangle-Strips mit runden Kappen an Start und Ende.
|
||||
pub fn finalize_stroke(raw_samples: &[StylusSample]) -> Stroke {
|
||||
finalize_stroke_with_config(0, raw_samples, [0.0, 0.0, 0.0, 1.0], &SplineConfig::default())
|
||||
finalize_stroke_with_config(
|
||||
0,
|
||||
raw_samples,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
&SplineConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Vollständige Finalisierung mit konfigurierbarer Stroke-ID, Farbe und SplineConfig.
|
||||
@@ -651,7 +665,10 @@ pub fn finalize_stroke_with_config(
|
||||
.map(|v| InkVertex::new(v.position[0], v.position[1], color))
|
||||
.collect();
|
||||
|
||||
Stroke::new(stroke_id, decimated, ink_vertices, color)
|
||||
let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color);
|
||||
stroke.width = config.min_radius + config.max_radius;
|
||||
stroke.finalized = true;
|
||||
stroke
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -808,9 +825,10 @@ mod tests {
|
||||
assert!(!stroke.vertices.is_empty());
|
||||
|
||||
// 2. Der Eckpunkt (100.0, 0.0) muss in den Strichpunkten erhalten bleiben
|
||||
let corner_found = stroke.points.iter().any(|s| {
|
||||
(s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3
|
||||
});
|
||||
let corner_found = stroke
|
||||
.points
|
||||
.iter()
|
||||
.any(|s| (s.position.x - 100.0).abs() < 1e-3 && (s.position.y - 0.0).abs() < 1e-3);
|
||||
assert!(
|
||||
corner_found,
|
||||
"Eckpunkt (100.0, 0.0) muss in den finalen Strichpunkten erhalten bleiben!"
|
||||
@@ -837,7 +855,11 @@ mod tests {
|
||||
|
||||
// 4. Ecken-Erkennung prüft den 90°-Winkel (> 65 Grad)
|
||||
let corners = detect_corners(&stroke.points, CORNER_ANGLE_THRESHOLD_DEG);
|
||||
assert_eq!(corners.len(), 1, "Exakt eine 90-Grad-Ecke muss erkannt werden");
|
||||
assert_eq!(
|
||||
corners.len(),
|
||||
1,
|
||||
"Exakt eine 90-Grad-Ecke muss erkannt werden"
|
||||
);
|
||||
let corner_idx = corners[0];
|
||||
let corner_pt = stroke.points[corner_idx];
|
||||
assert!((corner_pt.position.x - 100.0).abs() < 1e-3);
|
||||
@@ -847,9 +869,21 @@ mod tests {
|
||||
#[test]
|
||||
fn test_pressure_smoothing() {
|
||||
let samples = vec![
|
||||
StylusSample { position: glam::Vec2::new(0.0, 0.0), pressure: 0.2, timestamp: 0.0 },
|
||||
StylusSample { position: glam::Vec2::new(1.0, 0.0), pressure: 0.8, timestamp: 0.1 },
|
||||
StylusSample { position: glam::Vec2::new(2.0, 0.0), pressure: 0.2, timestamp: 0.2 },
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
pressure: 0.2,
|
||||
timestamp: 0.0,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(1.0, 0.0),
|
||||
pressure: 0.8,
|
||||
timestamp: 0.1,
|
||||
},
|
||||
StylusSample {
|
||||
position: glam::Vec2::new(2.0, 0.0),
|
||||
pressure: 0.2,
|
||||
timestamp: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
let smoothed = smooth_pressure(&samples);
|
||||
|
||||
+1721
-105
File diff suppressed because it is too large.
Load diff
+874
-163
File diff suppressed because it is too large.
Load diff
+44
-6
@@ -3,8 +3,8 @@
|
||||
//! Berechnet die affine Transformation zwischen Bildschirmkoordinaten (Window-Pixel),
|
||||
//! virtuellen DIN-A4-Seitenkoordinaten ([0, 2100] x [0, 2970]) und dem GPU-NDC-Raum ([-1, 1]).
|
||||
|
||||
use glam::{Mat4, Vec2, Vec4};
|
||||
use crate::document::{PageViewport, PAGE_HEIGHT, PAGE_WIDTH};
|
||||
use glam::{Mat4, Vec2, Vec4};
|
||||
|
||||
/// Minimaler und maximaler Zoomfaktor.
|
||||
pub const MIN_ZOOM: f32 = 0.25;
|
||||
@@ -40,7 +40,12 @@ impl ViewportState {
|
||||
}
|
||||
|
||||
/// Passt den Pan-Offset um eine Bildschirmverschiebung (in Fensterpixeln) an.
|
||||
pub fn pan_by_screen_delta(&mut self, delta_x: f32, delta_y: f32, page_viewport: &PageViewport) {
|
||||
pub fn pan_by_screen_delta(
|
||||
&mut self,
|
||||
delta_x: f32,
|
||||
delta_y: f32,
|
||||
page_viewport: &PageViewport,
|
||||
) {
|
||||
let base_scale = page_viewport.page_width / PAGE_WIDTH;
|
||||
if base_scale * self.zoom > 1e-6 {
|
||||
self.pan_offset[0] += delta_x / (base_scale * self.zoom);
|
||||
@@ -122,10 +127,7 @@ impl ViewportState {
|
||||
page_viewport: &PageViewport,
|
||||
) -> Vec2 {
|
||||
let pt = self.screen_to_page_unclamped(screen_x, screen_y, page_viewport);
|
||||
Vec2::new(
|
||||
pt.x.clamp(0.0, PAGE_WIDTH),
|
||||
pt.y.clamp(0.0, PAGE_HEIGHT),
|
||||
)
|
||||
Vec2::new(pt.x.clamp(0.0, PAGE_WIDTH), pt.y.clamp(0.0, PAGE_HEIGHT))
|
||||
}
|
||||
|
||||
/// Transformiert einen DIN-A4-Seitenpunkt in Fenster-Pixelkoordinaten.
|
||||
@@ -244,4 +246,40 @@ mod tests {
|
||||
assert!((offset1[0] - offset2[0]).abs() < 1e-5);
|
||||
assert!((offset1[1] - offset2[1]).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pinch_to_zoom_math() {
|
||||
let mut vp = ViewportState::new();
|
||||
let page_vp = PageViewport::calculate(1920.0, 1080.0, 24.0);
|
||||
|
||||
// Simulation von 2 Fingern:
|
||||
// Finger 1 bei (400, 500), Finger 2 bei (600, 500) -> Distanz: 200, Zentrum: (500, 500)
|
||||
let p1_start = (400.0_f32, 500.0_f32);
|
||||
let p2_start = (600.0_f32, 500.0_f32);
|
||||
let dist_start =
|
||||
((p2_start.0 - p1_start.0).powi(2) + (p2_start.1 - p1_start.1).powi(2)).sqrt();
|
||||
let center_start = (
|
||||
(p1_start.0 + p2_start.0) * 0.5,
|
||||
(p1_start.1 + p2_start.1) * 0.5,
|
||||
);
|
||||
assert_eq!(dist_start, 200.0);
|
||||
assert_eq!(center_start, (500.0, 500.0));
|
||||
|
||||
// Finger auseinanderziehen und leicht verschieben:
|
||||
// Finger 1 bei (350, 520), Finger 2 bei (650, 520) -> Distanz: 300, Zentrum: (500, 520)
|
||||
let p1_end = (350.0_f32, 520.0_f32);
|
||||
let p2_end = (650.0_f32, 520.0_f32);
|
||||
let dist_end = ((p2_end.0 - p1_end.0).powi(2) + (p2_end.1 - p1_end.1).powi(2)).sqrt();
|
||||
let center_end = ((p1_end.0 + p2_end.0) * 0.5, (p1_end.1 + p2_end.1) * 0.5);
|
||||
assert_eq!(dist_end, 300.0);
|
||||
assert_eq!(center_end, (500.0, 520.0));
|
||||
|
||||
let zoom_factor = dist_end / dist_start; // 1.5x Vergrößerung
|
||||
let pan_delta = (center_end.0 - center_start.0, center_end.1 - center_start.1); // (0, +20)
|
||||
|
||||
vp.pan_by_screen_delta(pan_delta.0, pan_delta.1, &page_vp);
|
||||
vp.zoom_at_cursor([center_end.0, center_end.1], zoom_factor, &page_vp);
|
||||
|
||||
assert!((vp.zoom - 1.5).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"format_version": 99,
|
||||
"id": "future_notebook",
|
||||
"title": "Zukunfts-Notizbuch",
|
||||
"created_at": 1800000000,
|
||||
"modified_at": 1800001000,
|
||||
"current_page_index": 0,
|
||||
"pages": [
|
||||
{ "id": 1, "background": "Grid5mm" }
|
||||
],
|
||||
"page_ids": [1],
|
||||
"next_stroke_id": 10,
|
||||
"next_page_id": 2
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"format_version": 99,
|
||||
"id": 1,
|
||||
"background": "Blank",
|
||||
"strokes": []
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "legacy_notebook",
|
||||
"title": "Legacy Notizbuch v0",
|
||||
"created_at": 1700000000,
|
||||
"modified_at": 1700001000,
|
||||
"current_page_index": 0,
|
||||
"page_ids": [1],
|
||||
"next_stroke_id": 5,
|
||||
"next_page_id": 2
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": 1,
|
||||
"background": "DotGrid",
|
||||
"strokes": [
|
||||
{
|
||||
"id": 1,
|
||||
"color": [0.1, 0.2, 0.3, 1.0],
|
||||
"samples": [
|
||||
{ "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 },
|
||||
{ "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"format_version": 1,
|
||||
"id": 1,
|
||||
"background": "Lined",
|
||||
"strokes": [],
|
||||
"unknown_experimental_feature": {
|
||||
"nested_data": [1, 2, 3],
|
||||
"author": "Antigravity"
|
||||
},
|
||||
"random_future_flag": true
|
||||
}
|
||||
Reference in new issue
Block a user