feat: PDF importieren und annotiert exportieren
- ☰ → „PDF importieren …“ oder PDF ins Fenster ziehen: neues Notizbuch mit einer Seite je PDF-Seite, die Originalseite als Hintergrund - Querformat-Folien liegen oben auf dem A4-Blatt, darunter ist Platz für Notizen; gedrehte Seiten (/Rotate) erscheinen wie im PDF-Betrachter - Anzeige mit hayro (reines Rust) im Hintergrund-Thread, nächste Seite wird vorab gerendert - PDF-Export legt die Originalseiten als Vektorgrafik (Form-XObject via lopdf) unter Bilder und Tinte; nicht übernehmbare Seiten (z. B. verschlüsselt) werden als Bild mit 200 dpi eingebettet - Dateiformat-Version 5 (pdf_background an der Seite) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+405
-13
@@ -37,6 +37,21 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -61,7 +76,7 @@ dependencies = [
|
|||||||
"ndk-context",
|
"ndk-context",
|
||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -205,6 +220,27 @@ dependencies = [
|
|||||||
"objc2 0.4.1",
|
"objc2 0.4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "8.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "5.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -265,7 +301,7 @@ dependencies = [
|
|||||||
"polling",
|
"polling",
|
||||||
"rustix 0.38.44",
|
"rustix 0.38.44",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -335,6 +371,15 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "color_quant"
|
name = "color_quant"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -593,7 +638,7 @@ dependencies = [
|
|||||||
"egui",
|
"egui",
|
||||||
"epaint",
|
"epaint",
|
||||||
"log",
|
"log",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"type-map",
|
"type-map",
|
||||||
"web-time",
|
"web-time",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
@@ -686,6 +731,15 @@ version = "3.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "euclid"
|
||||||
|
version = "0.22.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -707,6 +761,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fearless_simd"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4beca3cb2444e3304ac30843cc091f44ed58932353cd492ce740067bfce6b12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fearless_simd"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -724,6 +790,21 @@ dependencies = [
|
|||||||
"zlib-rs",
|
"zlib-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "font-types"
|
||||||
|
version = "0.12.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -878,6 +959,23 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glifo"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1218dedd5348c1b166ec3b8b3cbca49d2f527bd43b3fa072ea5123c523ed3f"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"foldhash",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"log",
|
||||||
|
"peniko",
|
||||||
|
"png",
|
||||||
|
"skrifa 0.44.0",
|
||||||
|
"smallvec",
|
||||||
|
"vello_common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glow"
|
name = "glow"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -926,7 +1024,7 @@ checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"presser",
|
"presser",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"winapi",
|
"winapi",
|
||||||
"windows 0.52.0",
|
"windows 0.52.0",
|
||||||
]
|
]
|
||||||
@@ -951,6 +1049,15 @@ dependencies = [
|
|||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "guillotiere"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
|
||||||
|
dependencies = [
|
||||||
|
"euclid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "half"
|
name = "half"
|
||||||
version = "2.7.1"
|
version = "2.7.1"
|
||||||
@@ -977,6 +1084,9 @@ name = "hashbrown"
|
|||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hassle-rs"
|
name = "hassle-rs"
|
||||||
@@ -988,11 +1098,103 @@ dependencies = [
|
|||||||
"com",
|
"com",
|
||||||
"libc",
|
"libc",
|
||||||
"libloading 0.8.9",
|
"libloading 0.8.9",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"widestring",
|
"widestring",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4675ff5acd7ca41c0c34ccd2a3a154f5c86d8b5ec92bef0a114bfb194969e2af"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"fearless_simd 1.1.0",
|
||||||
|
"hayro-interpret",
|
||||||
|
"image",
|
||||||
|
"kurbo",
|
||||||
|
"pic-scale",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"vello_cpu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-ccitt"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-cmap"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"hayro-postscript",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-interpret"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"hayro-cmap",
|
||||||
|
"hayro-syntax",
|
||||||
|
"kurbo",
|
||||||
|
"moxcms",
|
||||||
|
"phf",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"siphasher",
|
||||||
|
"skrifa 0.46.2",
|
||||||
|
"smallvec",
|
||||||
|
"yoke",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-jbig2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||||
|
dependencies = [
|
||||||
|
"fearless_simd 1.1.0",
|
||||||
|
"hayro-ccitt",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-jpeg2000"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
|
||||||
|
dependencies = [
|
||||||
|
"fearless_simd 1.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-postscript"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-syntax"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
|
||||||
|
dependencies = [
|
||||||
|
"flate2",
|
||||||
|
"hayro-ccitt",
|
||||||
|
"hayro-jbig2",
|
||||||
|
"hayro-jpeg2000",
|
||||||
|
"memchr",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"smallvec",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1185,6 +1387,7 @@ dependencies = [
|
|||||||
"egui-winit",
|
"egui-winit",
|
||||||
"fs2",
|
"fs2",
|
||||||
"glam",
|
"glam",
|
||||||
|
"hayro",
|
||||||
"image",
|
"image",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"naga",
|
"naga",
|
||||||
@@ -1220,7 +1423,7 @@ dependencies = [
|
|||||||
"combine",
|
"combine",
|
||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
"log",
|
"log",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
@@ -1291,6 +1494,18 @@ version = "3.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kurbo"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"euclid",
|
||||||
|
"polycool",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.190"
|
version = "0.2.190"
|
||||||
@@ -1329,6 +1544,12 @@ dependencies = [
|
|||||||
"redox_syscall 0.9.4",
|
"redox_syscall 0.9.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linebender_resource_handle"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linked-hash-map"
|
name = "linked-hash-map"
|
||||||
version = "0.5.6"
|
version = "0.5.6"
|
||||||
@@ -1494,7 +1715,7 @@ dependencies = [
|
|||||||
"rustc-hash 1.1.0",
|
"rustc-hash 1.1.0",
|
||||||
"spirv",
|
"spirv",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1510,7 +1731,7 @@ dependencies = [
|
|||||||
"ndk-sys",
|
"ndk-sys",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"raw-window-handle 0.6.2",
|
"raw-window-handle 0.6.2",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1697,7 +1918,7 @@ dependencies = [
|
|||||||
"raw-window-handle 0.6.2",
|
"raw-window-handle 0.6.2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"strum",
|
"strum",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-protocols",
|
"wayland-protocols",
|
||||||
@@ -1767,12 +1988,77 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "peniko"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"color",
|
||||||
|
"kurbo",
|
||||||
|
"linebender_resource_handle",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator",
|
||||||
|
"phf_shared",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pic-scale"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1824,6 +2110,15 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
|
checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polycool"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pom"
|
name = "pom"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -1950,6 +2245,28 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "read-fonts"
|
||||||
|
version = "0.41.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"font-types",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "read-fonts"
|
||||||
|
version = "0.43.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"font-types",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
@@ -2194,6 +2511,32 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "skrifa"
|
||||||
|
version = "0.44.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"read-fonts 0.41.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "skrifa"
|
||||||
|
version = "0.46.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"read-fonts 0.43.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -2229,7 +2572,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"rustix 0.38.44",
|
"rustix 0.38.44",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"wayland-backend",
|
"wayland-backend",
|
||||||
"wayland-client",
|
"wayland-client",
|
||||||
"wayland-csd-frame",
|
"wayland-csd-frame",
|
||||||
@@ -2376,7 +2719,16 @@ version = "1.0.69"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2390,6 +2742,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiff"
|
name = "tiff"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -2601,6 +2964,35 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vello_common"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73c2fbe53548bcd25c8ff42f75a93770a8611a16b37bee56d7ef7b44e36027f1"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"fearless_simd 0.7.0",
|
||||||
|
"guillotiere",
|
||||||
|
"log",
|
||||||
|
"peniko",
|
||||||
|
"png",
|
||||||
|
"smallvec",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vello_cpu"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed1fec424fbba6acf6cbc78bb526447eb77590d95f40419d598870fe37535212"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"glifo",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"png",
|
||||||
|
"vello_common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -2903,7 +3295,7 @@ dependencies = [
|
|||||||
"raw-window-handle 0.6.2",
|
"raw-window-handle 0.6.2",
|
||||||
"rustc-hash 1.1.0",
|
"rustc-hash 1.1.0",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
@@ -2947,7 +3339,7 @@ dependencies = [
|
|||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"rustc-hash 1.1.0",
|
"rustc-hash 1.1.0",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"wgpu-types",
|
"wgpu-types",
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linu
|
|||||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
rust-version = "1.75"
|
rust-version = "1.92"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
# Fenster- & Event-Handling (Wayland/X11, Stylus)
|
||||||
@@ -45,6 +45,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
|
|||||||
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
arboard = { version = "3", default-features = false, features = ["image-data"] }
|
||||||
minisign-verify = "0.3.0"
|
minisign-verify = "0.3.0"
|
||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
|
hayro = "0.8"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||||
|
|||||||
+13
-2
@@ -78,7 +78,7 @@ pub struct RgbaImage {
|
|||||||
pub pixels: Vec<u8>,
|
pub pixels: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png` oder `.jpg`. Verhindert, dass
|
/// Prüft einen Asset-Namen: genau 64 Hex-Zeichen und `.png`, `.jpg` oder `.pdf`. Verhindert, dass
|
||||||
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
|
/// eine manipulierte Seitendatei auf Dateien außerhalb des Asset-Ordners verweist.
|
||||||
pub fn is_valid_asset_name(name: &str) -> bool {
|
pub fn is_valid_asset_name(name: &str) -> bool {
|
||||||
let Some((hash, ext)) = name.split_once('.') else {
|
let Some((hash, ext)) = name.split_once('.') else {
|
||||||
@@ -88,7 +88,7 @@ pub fn is_valid_asset_name(name: &str) -> bool {
|
|||||||
&& hash
|
&& hash
|
||||||
.bytes()
|
.bytes()
|
||||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||||
&& matches!(ext, "png" | "jpg")
|
&& matches!(ext, "png" | "jpg" | "pdf")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bilddateien eines Notizbuchs.
|
/// Bilddateien eines Notizbuchs.
|
||||||
@@ -146,6 +146,17 @@ impl AssetStore {
|
|||||||
self.store(&png, "png", decoded.width(), decoded.height())
|
self.store(&png, "png", decoded.width(), decoded.height())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Übernimmt eine PDF-Datei unverändert (der Inhalt muss vorher geprüft sein).
|
||||||
|
pub fn import_pdf(&self, bytes: &[u8]) -> Result<String, AssetError> {
|
||||||
|
let hash = Sha256::digest(bytes);
|
||||||
|
let asset = format!("{}.pdf", hex(&hash));
|
||||||
|
let path = self.path_of(&asset)?;
|
||||||
|
if !path.exists() {
|
||||||
|
storage::atomic_write_file(&path, bytes)?;
|
||||||
|
}
|
||||||
|
Ok(asset)
|
||||||
|
}
|
||||||
|
|
||||||
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
|
/// Übernimmt rohe RGBA-Pixel (z. B. aus der Zwischenablage) als PNG.
|
||||||
pub fn import_rgba(
|
pub fn import_rgba(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
+19
-5
@@ -666,10 +666,16 @@ impl CanvasState {
|
|||||||
pub fn sync_page(&mut self, page: &mut Page) {
|
pub fn sync_page(&mut self, page: &mut Page) {
|
||||||
page.rebuild_cache_if_dirty();
|
page.rebuild_cache_if_dirty();
|
||||||
self.set_background(page.background);
|
self.set_background(page.background);
|
||||||
|
// PDF-Seite zuerst, darüber die eingefügten Bilder
|
||||||
self.image_layer.set_quads(
|
self.image_layer.set_quads(
|
||||||
|
page.pdf_background
|
||||||
|
.iter()
|
||||||
|
.map(|pdf| (pdf.key(), pdf.corners()))
|
||||||
|
.chain(
|
||||||
page.strokes
|
page.strokes
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?)))
|
.filter_map(|s| Some((s.image.clone()?, s.image_corners()?))),
|
||||||
|
)
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -698,10 +704,18 @@ impl CanvasState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt Bilder der aktuellen Seite, die noch nicht auf der GPU liegen. Vor jedem
|
/// Lädt Bilder und PDF-Seiten der aktuellen Seite, die noch nicht auf der GPU liegen.
|
||||||
/// Zeichnen aufrufen; ohne Änderungen kostet das nichts.
|
/// Vor jedem Zeichnen aufrufen; ohne Änderungen kostet das fast nichts.
|
||||||
pub fn prepare_images(&mut self, assets: &crate::assets::AssetStore) {
|
pub fn prepare_images(
|
||||||
self.image_layer.prepare(&self.device, &self.queue, assets);
|
&mut self,
|
||||||
|
load: &mut dyn FnMut(&str) -> crate::image_layer::TextureSource,
|
||||||
|
) {
|
||||||
|
self.image_layer.prepare(&self.device, &self.queue, load);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wird noch eine PDF-Seite im Hintergrund gerendert?
|
||||||
|
pub fn is_waiting_for_images(&self) -> bool {
|
||||||
|
self.image_layer.is_waiting()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
|
/// Startet einen neuen Live-Strich in DIN-A4-Seitenkoordinaten.
|
||||||
|
|||||||
@@ -122,6 +122,47 @@ pub fn point_in_convex_quad(p: glam::Vec2, quad: &[glam::Vec2; 4]) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verweis auf eine Seite eines importierten PDFs (Datei im Asset-Ordner).
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PdfBackground {
|
||||||
|
/// Name der PDF-Datei im Asset-Ordner
|
||||||
|
pub asset: String,
|
||||||
|
/// Seitennummer im PDF, ab 0
|
||||||
|
pub page: u32,
|
||||||
|
/// Angezeigte Größe der PDF-Seite in Punkt (Drehung berücksichtigt)
|
||||||
|
pub width_pt: f32,
|
||||||
|
pub height_pt: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfBackground {
|
||||||
|
/// Schlüssel für Texturen und Render-Aufträge.
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
format!("{}#{}", self.asset, self.page)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ecken (oben links, oben rechts, unten rechts, unten links) der PDF-Seite auf dem
|
||||||
|
/// A4-Blatt, siehe [`pdf_placement`].
|
||||||
|
pub fn corners(&self) -> [glam::Vec2; 4] {
|
||||||
|
pdf_placement(self.width_pt, self.height_pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lage einer PDF-Seite von `width_pt` × `height_pt` auf dem A4-Blatt: so groß wie möglich,
|
||||||
|
/// waagerecht zentriert und oben bündig. Querformat-Folien liegen so oben auf der Seite,
|
||||||
|
/// darunter bleibt Platz für Notizen.
|
||||||
|
pub fn pdf_placement(width_pt: f32, height_pt: f32) -> [glam::Vec2; 4] {
|
||||||
|
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
|
||||||
|
let scale = (PAGE_WIDTH / w).min(PAGE_HEIGHT / h);
|
||||||
|
let size = glam::Vec2::new(w * scale, h * scale);
|
||||||
|
let tl = glam::Vec2::new((PAGE_WIDTH - size.x) / 2.0, 0.0);
|
||||||
|
[
|
||||||
|
tl,
|
||||||
|
tl + glam::Vec2::new(size.x, 0.0),
|
||||||
|
tl + size,
|
||||||
|
tl + glam::Vec2::new(0.0, size.y),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// Seiteneinheiten pro Bildpixel beim Einfügen (96 dpi wie am Bildschirm: 1 px ≈ 0,26 mm).
|
/// 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;
|
pub const IMAGE_UNITS_PER_PIXEL: f32 = UNITS_PER_MM * 25.4 / 96.0;
|
||||||
|
|
||||||
@@ -489,6 +530,8 @@ pub struct Page {
|
|||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub strokes: Vec<Stroke>,
|
pub strokes: Vec<Stroke>,
|
||||||
pub background: PageBackground,
|
pub background: PageBackground,
|
||||||
|
/// Importierte PDF-Seite als Hintergrund (unter Bildern und Tinte)
|
||||||
|
pub pdf_background: Option<PdfBackground>,
|
||||||
pub vertex_cache: Vec<InkVertex>,
|
pub vertex_cache: Vec<InkVertex>,
|
||||||
pub stroke_ranges: Vec<Range<u32>>,
|
pub stroke_ranges: Vec<Range<u32>>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
@@ -503,6 +546,7 @@ impl Page {
|
|||||||
id,
|
id,
|
||||||
strokes: Vec::new(),
|
strokes: Vec::new(),
|
||||||
background,
|
background,
|
||||||
|
pdf_background: None,
|
||||||
vertex_cache: Vec::new(),
|
vertex_cache: Vec::new(),
|
||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
@@ -518,6 +562,7 @@ impl Page {
|
|||||||
id,
|
id,
|
||||||
strokes: Vec::new(),
|
strokes: Vec::new(),
|
||||||
background,
|
background,
|
||||||
|
pdf_background: None,
|
||||||
vertex_cache: Vec::new(),
|
vertex_cache: Vec::new(),
|
||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
|
|||||||
+209
-22
@@ -117,10 +117,11 @@ pub fn export_notebook_to_pdf<P: AsRef<Path>>(
|
|||||||
strategy: ExportStrategy,
|
strategy: ExportStrategy,
|
||||||
assets: Option<&AssetStore>,
|
assets: Option<&AssetStore>,
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let bytes = export_notebook_to_bytes(notebook, strategy, assets)?;
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
let mut writer = BufWriter::new(file);
|
let mut writer = BufWriter::new(file);
|
||||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
std::io::Write::write_all(&mut writer, &bytes)?;
|
||||||
doc.save(&mut writer)?;
|
std::io::Write::flush(&mut writer)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +133,88 @@ pub fn export_notebook_to_bytes(
|
|||||||
) -> Result<Vec<u8>, Box<dyn Error>> {
|
) -> Result<Vec<u8>, Box<dyn Error>> {
|
||||||
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
let doc = generate_pdf_document(notebook, strategy, assets)?;
|
||||||
let bytes = doc.save_to_bytes()?;
|
let bytes = doc.save_to_bytes()?;
|
||||||
Ok(bytes)
|
|
||||||
|
// Importierte PDF-Seiten als Vektorgrafik unterlegen
|
||||||
|
let underlays = pdf_underlays(notebook);
|
||||||
|
if underlays.is_empty() {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
let sources: std::collections::HashMap<String, Vec<u8>> = underlays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|u| Some((u.source.clone(), assets?.read_bytes(&u.source).ok()?)))
|
||||||
|
.collect();
|
||||||
|
let (merged, failed) = crate::pdf_merge::apply_underlays(&bytes, &underlays, &sources)?;
|
||||||
|
if failed.is_empty() {
|
||||||
|
return Ok(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht übernehmbare Seiten (z. B. verschlüsselte PDFs) als Bild einbetten
|
||||||
|
let mut raster = std::collections::HashSet::new();
|
||||||
|
for (underlay, err) in &failed {
|
||||||
|
eprintln!(
|
||||||
|
"[PDF-Export] Seite {} des PDFs als Bild statt als Vektor: {err}",
|
||||||
|
underlay.source_page + 1
|
||||||
|
);
|
||||||
|
raster.insert(underlay.target_page);
|
||||||
|
}
|
||||||
|
let doc = generate_pdf_document_with(notebook, strategy, assets, &raster)?;
|
||||||
|
let bytes = doc.save_to_bytes()?;
|
||||||
|
let remaining: Vec<_> = underlays
|
||||||
|
.into_iter()
|
||||||
|
.filter(|u| !raster.contains(&u.target_page))
|
||||||
|
.collect();
|
||||||
|
if remaining.is_empty() {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
let (merged, _) = crate::pdf_merge::apply_underlays(&bytes, &remaining, &sources)?;
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
|
||||||
|
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
|
||||||
|
notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, page)| {
|
||||||
|
let pdf = page.pdf_background.as_ref()?;
|
||||||
|
let corners = pdf.corners();
|
||||||
|
let top_right = page_to_pdf_pt(corners[1]);
|
||||||
|
let bottom_left = page_to_pdf_pt(corners[3]);
|
||||||
|
let size = top_right - bottom_left;
|
||||||
|
Some(crate::pdf_merge::Underlay {
|
||||||
|
target_page: i,
|
||||||
|
source: pdf.asset.clone(),
|
||||||
|
source_page: pdf.page as usize,
|
||||||
|
rect: [bottom_left.x, bottom_left.y, size.x, size.y],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
|
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
|
||||||
|
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
|
||||||
|
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
|
||||||
pub fn generate_pdf_document(
|
pub fn generate_pdf_document(
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
strategy: ExportStrategy,
|
strategy: ExportStrategy,
|
||||||
assets: Option<&AssetStore>,
|
assets: Option<&AssetStore>,
|
||||||
|
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||||
|
generate_pdf_document_with(
|
||||||
|
notebook,
|
||||||
|
strategy,
|
||||||
|
assets,
|
||||||
|
&std::collections::HashSet::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [`generate_pdf_document`]; die PDF-Seiten der Seiten in `raster_pdf_pages` werden
|
||||||
|
/// gerendert und als Bild eingebettet.
|
||||||
|
fn generate_pdf_document_with(
|
||||||
|
notebook: &Notebook,
|
||||||
|
strategy: ExportStrategy,
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
|
raster_pdf_pages: &std::collections::HashSet<usize>,
|
||||||
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
) -> Result<PdfDocumentReference, Box<dyn Error>> {
|
||||||
if notebook.pages.is_empty() {
|
if notebook.pages.is_empty() {
|
||||||
return Err("Notizbuch enthält keine Seiten".into());
|
return Err("Notizbuch enthält keine Seiten".into());
|
||||||
@@ -164,6 +239,9 @@ pub fn generate_pdf_document(
|
|||||||
let page1_ref = doc.get_page(page1_idx);
|
let page1_ref = doc.get_page(page1_idx);
|
||||||
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
|
let layer1_bg_ref = page1_ref.get_layer(layer1_bg);
|
||||||
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
|
render_background_layer(&layer1_bg_ref, notebook.pages[0].background);
|
||||||
|
if raster_pdf_pages.contains(&0) {
|
||||||
|
render_pdf_raster(&page1_ref.add_layer("PDF"), ¬ebook.pages[0], assets);
|
||||||
|
}
|
||||||
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
if notebook.pages[0].strokes.iter().any(Stroke::is_image) {
|
||||||
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
render_image_layer(&page1_ref.add_layer("Images"), ¬ebook.pages[0], assets);
|
||||||
}
|
}
|
||||||
@@ -172,11 +250,14 @@ pub fn generate_pdf_document(
|
|||||||
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
render_ink_layer(&layer1_ink_ref, ¬ebook.pages[0], strategy);
|
||||||
|
|
||||||
// 2. Alle nachfolgenden Seiten hinzufügen
|
// 2. Alle nachfolgenden Seiten hinzufügen
|
||||||
for page in ¬ebook.pages[1..] {
|
for (i, page) in notebook.pages.iter().enumerate().skip(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 page_ref = doc.get_page(page_idx);
|
||||||
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
let layer_bg_ref = page_ref.get_layer(layer_bg);
|
||||||
render_background_layer(&layer_bg_ref, page.background);
|
render_background_layer(&layer_bg_ref, page.background);
|
||||||
|
if raster_pdf_pages.contains(&i) {
|
||||||
|
render_pdf_raster(&page_ref.add_layer("PDF"), page, assets);
|
||||||
|
}
|
||||||
if page.strokes.iter().any(Stroke::is_image) {
|
if page.strokes.iter().any(Stroke::is_image) {
|
||||||
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
render_image_layer(&page_ref.add_layer("Images"), page, assets);
|
||||||
}
|
}
|
||||||
@@ -390,17 +471,7 @@ fn pdf_image_xobject(
|
|||||||
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
|
rgb.push(((*c as u32 * alpha + 255 * (255 - alpha)) / 255) as u8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ImageXObject {
|
Ok(rgb_xobject(rgba.width(), rgba.height(), rgb))
|
||||||
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
|
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
|
||||||
@@ -420,22 +491,42 @@ fn render_image_layer(
|
|||||||
if let Some(Err(err)) = other {
|
if let Some(Err(err)) = other {
|
||||||
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
|
eprintln!("[PDF-Export] Bild '{asset}' fehlt oder ist beschädigt: {err}");
|
||||||
}
|
}
|
||||||
|
placeholder_xobject()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
draw_image_xobject(layer, xobject, &corners);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graue 1×1-Fläche für fehlende Bilder.
|
||||||
|
fn placeholder_xobject() -> printpdf::ImageXObject {
|
||||||
|
rgb_xobject(1, 1, vec![200, 200, 200])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> printpdf::ImageXObject {
|
||||||
printpdf::ImageXObject {
|
printpdf::ImageXObject {
|
||||||
width: printpdf::Px(1),
|
width: printpdf::Px(width as usize),
|
||||||
height: printpdf::Px(1),
|
height: printpdf::Px(height as usize),
|
||||||
color_space: printpdf::ColorSpace::Rgb,
|
color_space: printpdf::ColorSpace::Rgb,
|
||||||
bits_per_component: printpdf::ColorBits::Bit8,
|
bits_per_component: printpdf::ColorBits::Bit8,
|
||||||
interpolate: false,
|
interpolate: true,
|
||||||
image_data: vec![200, 200, 200],
|
image_data: rgb,
|
||||||
image_filter: None,
|
image_filter: None,
|
||||||
smask: None,
|
smask: None,
|
||||||
clipping_bbox: None,
|
clipping_bbox: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
|
||||||
|
/// unten links ausfüllt.
|
||||||
|
fn draw_image_xobject(
|
||||||
|
layer: &printpdf::PdfLayerReference,
|
||||||
|
xobject: printpdf::ImageXObject,
|
||||||
|
corners: &[glam::Vec2; 4],
|
||||||
|
) {
|
||||||
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
|
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
|
||||||
layer.save_graphics_state();
|
layer.save_graphics_state();
|
||||||
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(&corners)));
|
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners)));
|
||||||
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
|
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
|
||||||
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
|
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
|
||||||
printpdf::Image::from(xobject).add_to_layer(
|
printpdf::Image::from(xobject).add_to_layer(
|
||||||
@@ -448,7 +539,45 @@ fn render_image_layer(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
layer.restore_graphics_state();
|
layer.restore_graphics_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auflösung, mit der PDF-Seiten gerendert werden, die nicht als Vektor übernommen
|
||||||
|
/// werden können.
|
||||||
|
const PDF_RASTER_DPI: f32 = 200.0;
|
||||||
|
|
||||||
|
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
|
||||||
|
fn render_pdf_raster(
|
||||||
|
layer: &printpdf::PdfLayerReference,
|
||||||
|
page: &Page,
|
||||||
|
assets: Option<&AssetStore>,
|
||||||
|
) {
|
||||||
|
let Some(pdf) = &page.pdf_background else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let longest_pt = pdf.width_pt.max(pdf.height_pt);
|
||||||
|
let max_pixels = (longest_pt / 72.0 * PDF_RASTER_DPI).ceil() as u32;
|
||||||
|
let rendered = assets
|
||||||
|
.ok_or_else(|| "kein Asset-Ordner".to_string())
|
||||||
|
.and_then(|a| a.path_of(&pdf.asset).map_err(|e| e.to_string()))
|
||||||
|
.and_then(|path| crate::pdf_doc::render_file_page(&path, pdf.page as usize, max_pixels));
|
||||||
|
let xobject = match rendered {
|
||||||
|
Ok(image) => {
|
||||||
|
let rgb = image
|
||||||
|
.pixels
|
||||||
|
.chunks_exact(4)
|
||||||
|
.flat_map(|px| [px[0], px[1], px[2]])
|
||||||
|
.collect();
|
||||||
|
rgb_xobject(image.width, image.height, rgb)
|
||||||
}
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"[PDF-Export] PDF-Seite {} nicht darstellbar: {err}",
|
||||||
|
pdf.page + 1
|
||||||
|
);
|
||||||
|
placeholder_xobject()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
draw_image_xobject(layer, xobject, &pdf.corners());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
|
||||||
@@ -1209,6 +1338,64 @@ mod tests {
|
|||||||
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pdf_export_underlays_imported_pages() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 90)]);
|
||||||
|
let (mut notebook, _, dir) =
|
||||||
|
crate::storage::create_notebook_from_pdf(temp_dir.path(), "Folien", source).unwrap();
|
||||||
|
crate::storage::load_all_pages(&dir, &mut notebook).unwrap();
|
||||||
|
let assets = AssetStore::new(&dir);
|
||||||
|
|
||||||
|
let bytes =
|
||||||
|
export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, Some(&assets))
|
||||||
|
.unwrap();
|
||||||
|
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
|
||||||
|
assert_eq!(doc.get_pages().len(), 2);
|
||||||
|
for page_id in doc.get_pages().into_values() {
|
||||||
|
let content = doc.get_page_content(page_id).unwrap();
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&content).starts_with("q "),
|
||||||
|
"Originalseite fehlt als Unterlage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportiertes PDF lässt sich wieder rendern: Folie oben, rotes Quadrat oben links
|
||||||
|
let pdf = hayro::hayro_syntax::Pdf::new(bytes).unwrap();
|
||||||
|
let image = crate::pdf_doc::render_page(&pdf, 0, 600).unwrap();
|
||||||
|
let px = |x: u32, y: u32| {
|
||||||
|
let i = ((y * image.width + x) * 4) as usize;
|
||||||
|
image.pixels[i..i + 3].to_vec()
|
||||||
|
};
|
||||||
|
assert_eq!(px(5, 5), vec![255, 0, 0], "Quadrat der Folie oben links");
|
||||||
|
assert_eq!(
|
||||||
|
px(200, 500),
|
||||||
|
vec![255, 255, 255],
|
||||||
|
"unter der Folie ist Platz"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gedrehte Seite (/Rotate 90) erscheint wie im PDF-Betrachter: das Quadrat liegt
|
||||||
|
// dort oben rechts, in der Quelle wie im Export
|
||||||
|
let source_pdf = hayro::hayro_syntax::Pdf::new(
|
||||||
|
assets
|
||||||
|
.read_bytes(¬ebook.pages[1].pdf_background.as_ref().unwrap().asset)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let shown = crate::pdf_doc::render_page(&source_pdf, 1, 600).unwrap();
|
||||||
|
let red_at = |img: &crate::assets::RgbaImage, x: u32, y: u32| {
|
||||||
|
let i = ((y * img.width + x) * 4) as usize;
|
||||||
|
img.pixels[i..i + 3] == [255, 0, 0]
|
||||||
|
};
|
||||||
|
assert!(red_at(&shown, shown.width - 5, 5));
|
||||||
|
let exported = crate::pdf_doc::render_page(&pdf, 1, 600).unwrap();
|
||||||
|
assert!(red_at(&exported, exported.width - 5, 5));
|
||||||
|
assert!(!red_at(&exported, 5, 5));
|
||||||
|
|
||||||
|
// Ohne Zugriff auf das PDF: Export klappt trotzdem (graue Ersatzfläche)
|
||||||
|
assert!(export_notebook_to_bytes(¬ebook, ExportStrategy::FilledPolygon, None).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_jpeg_component_count() {
|
fn test_jpeg_component_count() {
|
||||||
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
let color = crate::assets::tests::sample_image_bytes(8, 8, image::ImageFormat::Jpeg);
|
||||||
|
|||||||
+66
-14
@@ -1,7 +1,7 @@
|
|||||||
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder.
|
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und PDFs.
|
||||||
//!
|
//!
|
||||||
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
|
||||||
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und unterstützte Bilder des
|
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
|
||||||
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
|
//! aktuellen Ordners; versteckte Dateien bleiben ausgeblendet.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -16,9 +16,36 @@ pub struct PickerEntry {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welche Dateien die Auswahl anbietet.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PickerKind {
|
||||||
|
/// Bilder zum Einfügen auf der aktuellen Seite
|
||||||
|
Image,
|
||||||
|
/// PDF zum Importieren als neues Notizbuch
|
||||||
|
Pdf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PickerKind {
|
||||||
|
/// Passt die Datei zu dieser Auswahl?
|
||||||
|
pub fn accepts(self, path: &Path) -> bool {
|
||||||
|
match self {
|
||||||
|
PickerKind::Image => is_supported_image_path(path),
|
||||||
|
PickerKind::Pdf => is_pdf_path(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist die Datei dem Namen nach ein PDF?
|
||||||
|
pub fn is_pdf_path(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Zustand der Dateiauswahl.
|
/// Zustand der Dateiauswahl.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct FilePicker {
|
pub struct FilePicker {
|
||||||
|
pub kind: PickerKind,
|
||||||
pub dir: PathBuf,
|
pub dir: PathBuf,
|
||||||
pub entries: Vec<PickerEntry>,
|
pub entries: Vec<PickerEntry>,
|
||||||
/// Ordner nicht lesbar
|
/// Ordner nicht lesbar
|
||||||
@@ -26,14 +53,26 @@ pub struct FilePicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FilePicker {
|
impl FilePicker {
|
||||||
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner, sonst im Home-Ordner.
|
/// Öffnet die Auswahl in `dir`, sonst im Bilder-Ordner (Bilder) bzw. Downloads (PDF),
|
||||||
pub fn open(dir: Option<&Path>) -> Self {
|
/// sonst im Home-Ordner.
|
||||||
|
pub fn open(kind: PickerKind, dir: Option<&Path>) -> Self {
|
||||||
let start = dir
|
let start = dir
|
||||||
.filter(|d| d.is_dir())
|
.filter(|d| d.is_dir())
|
||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.or_else(|| shortcuts().into_iter().map(|(_, p)| p).find(|p| p.is_dir()))
|
.or_else(|| {
|
||||||
|
let preferred = match kind {
|
||||||
|
PickerKind::Image => "🖼 Bilder",
|
||||||
|
PickerKind::Pdf => "⬇ Downloads",
|
||||||
|
};
|
||||||
|
let all = shortcuts();
|
||||||
|
all.iter()
|
||||||
|
.find(|(label, _)| *label == preferred)
|
||||||
|
.or_else(|| all.last())
|
||||||
|
.map(|(_, p)| p.clone())
|
||||||
|
})
|
||||||
.unwrap_or_else(|| PathBuf::from("/"));
|
.unwrap_or_else(|| PathBuf::from("/"));
|
||||||
let mut picker = Self {
|
let mut picker = Self {
|
||||||
|
kind,
|
||||||
dir: start.clone(),
|
dir: start.clone(),
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
error: None,
|
error: None,
|
||||||
@@ -44,7 +83,7 @@ impl FilePicker {
|
|||||||
|
|
||||||
/// Wechselt in `dir` und liest den Inhalt neu ein.
|
/// Wechselt in `dir` und liest den Inhalt neu ein.
|
||||||
pub fn navigate(&mut self, dir: &Path) {
|
pub fn navigate(&mut self, dir: &Path) {
|
||||||
match list_dir(dir) {
|
match list_dir(dir, self.kind) {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
self.dir = dir.to_path_buf();
|
self.dir = dir.to_path_buf();
|
||||||
self.entries = entries;
|
self.entries = entries;
|
||||||
@@ -67,9 +106,9 @@ impl FilePicker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordner und unterstützte Bilder in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
|
/// Ordner und passende Dateien in `dir`: erst Ordner, dann Dateien, jeweils alphabetisch
|
||||||
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
|
/// ohne Groß-/Kleinschreibung; versteckte Einträge (Punkt am Anfang) fehlen.
|
||||||
pub fn list_dir(dir: &Path) -> std::io::Result<Vec<PickerEntry>> {
|
pub fn list_dir(dir: &Path, kind: PickerKind) -> std::io::Result<Vec<PickerEntry>> {
|
||||||
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
|
let mut entries: Vec<PickerEntry> = std::fs::read_dir(dir)?
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
.filter_map(|entry| {
|
.filter_map(|entry| {
|
||||||
@@ -80,7 +119,7 @@ pub fn list_dir(dir: &Path) -> std::io::Result<Vec<PickerEntry>> {
|
|||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
|
// Symlinks auflösen: ein Link auf einen Ordner ist ein Ordner
|
||||||
let is_dir = path.is_dir();
|
let is_dir = path.is_dir();
|
||||||
(is_dir || is_supported_image_path(&path)).then_some(PickerEntry { name, path, is_dir })
|
(is_dir || kind.accepts(&path)).then_some(PickerEntry { name, path, is_dir })
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| {
|
entries.sort_by(|a, b| {
|
||||||
@@ -143,19 +182,32 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_list_dir_sorts_and_filters() {
|
fn test_list_dir_sorts_and_filters() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
for name in ["b.png", "A.jpg", "notiz.txt", ".versteckt.png", "c.webp"] {
|
for name in [
|
||||||
|
"b.png",
|
||||||
|
"A.jpg",
|
||||||
|
"notiz.txt",
|
||||||
|
".versteckt.png",
|
||||||
|
"c.webp",
|
||||||
|
"Skript.PDF",
|
||||||
|
] {
|
||||||
std::fs::write(dir.path().join(name), b"x").unwrap();
|
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("zOrdner")).unwrap();
|
||||||
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
|
std::fs::create_dir(dir.path().join("aOrdner")).unwrap();
|
||||||
std::fs::create_dir(dir.path().join(".cache")).unwrap();
|
std::fs::create_dir(dir.path().join(".cache")).unwrap();
|
||||||
|
|
||||||
let names: Vec<String> = list_dir(dir.path())
|
let names = |kind| -> Vec<String> {
|
||||||
|
list_dir(dir.path(), kind)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| e.name)
|
.map(|e| e.name)
|
||||||
.collect();
|
.collect()
|
||||||
assert_eq!(names, ["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]);
|
};
|
||||||
|
assert_eq!(
|
||||||
|
names(PickerKind::Image),
|
||||||
|
["aOrdner", "zOrdner", "A.jpg", "b.png", "c.webp"]
|
||||||
|
);
|
||||||
|
assert_eq!(names(PickerKind::Pdf), ["aOrdner", "zOrdner", "Skript.PDF"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -165,7 +217,7 @@ mod tests {
|
|||||||
std::fs::create_dir(&sub).unwrap();
|
std::fs::create_dir(&sub).unwrap();
|
||||||
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
|
std::fs::write(sub.join("tafel.jpg"), b"x").unwrap();
|
||||||
|
|
||||||
let mut picker = FilePicker::open(Some(&sub));
|
let mut picker = FilePicker::open(PickerKind::Image, Some(&sub));
|
||||||
assert_eq!(picker.dir, sub);
|
assert_eq!(picker.dir, sub);
|
||||||
assert_eq!(picker.entries.len(), 1);
|
assert_eq!(picker.entries.len(), 1);
|
||||||
|
|
||||||
|
|||||||
+60
-31
@@ -1,13 +1,13 @@
|
|||||||
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
|
//! GPU-Darstellung eingefügter Bilder: ein texturiertes Viereck pro Bild, gezeichnet nach dem
|
||||||
//! Papier und vor der Tinte.
|
//! Papier und vor der Tinte.
|
||||||
//!
|
//!
|
||||||
//! Texturen werden beim ersten Bedarf aus dem Asset-Ordner geladen (verkleinert auf
|
//! Texturen liefert beim ersten Bedarf eine Texturquelle (Bilddatei oder gerenderte
|
||||||
//! `assets::MAX_DISPLAY_DIMENSION`) und mit Mipmaps versehen, damit verkleinerte Bilder
|
//! PDF-Seite); sie werden mit Mipmaps versehen, damit verkleinerte Bilder beim
|
||||||
//! beim Herauszoomen nicht flimmern.
|
//! Herauszoomen nicht flimmern.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::assets::{AssetStore, RgbaImage, MAX_DISPLAY_DIMENSION};
|
use crate::assets::RgbaImage;
|
||||||
|
|
||||||
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
|
/// Vertex eines Bildvierecks: Seitenkoordinate und Texturkoordinate.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -67,19 +67,29 @@ pub fn mip_chain(base: &RgbaImage) -> Vec<RgbaImage> {
|
|||||||
|
|
||||||
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
/// Name der Ersatztextur für Bilder, deren Datei fehlt oder beschädigt ist.
|
||||||
const MISSING: &str = "";
|
const MISSING: &str = "";
|
||||||
/// So viele Texturen bleiben höchstens im Speicher, auch wenn sie auf der aktuellen Seite
|
/// Bis zu dieser Grafikspeichermenge bleiben Texturen anderer Seiten erhalten (schnelles
|
||||||
/// nicht mehr gebraucht werden (schnelles Zurückblättern).
|
/// Zurückblättern); darüber werden alle nicht mehr sichtbaren freigegeben.
|
||||||
const TEXTURE_CACHE_LIMIT: usize = 24;
|
const TEXTURE_CACHE_BYTES: u64 = 384 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Antwort einer Texturquelle für einen Schlüssel.
|
||||||
|
pub enum TextureSource {
|
||||||
|
Ready(RgbaImage),
|
||||||
|
/// Wird gerade erzeugt; später erneut fragen
|
||||||
|
Pending,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ImageLayer {
|
pub struct ImageLayer {
|
||||||
pipeline: wgpu::RenderPipeline,
|
pipeline: wgpu::RenderPipeline,
|
||||||
texture_layout: wgpu::BindGroupLayout,
|
texture_layout: wgpu::BindGroupLayout,
|
||||||
sampler: wgpu::Sampler,
|
sampler: wgpu::Sampler,
|
||||||
textures: HashMap<String, wgpu::BindGroup>,
|
/// Texturen nach Schlüssel (Asset-Name bzw. `<pdf>#<seite>`) mit belegtem Speicher
|
||||||
|
textures: HashMap<String, (wgpu::BindGroup, u64)>,
|
||||||
|
texture_bytes: u64,
|
||||||
/// Bilder der aktuellen Seite: Asset-Name und Ecken
|
/// Bilder der aktuellen Seite: Asset-Name und Ecken
|
||||||
quads: Vec<(String, [glam::Vec2; 4])>,
|
quads: Vec<(String, [glam::Vec2; 4])>,
|
||||||
vertex_buffer: Option<wgpu::Buffer>,
|
vertex_buffer: Option<wgpu::Buffer>,
|
||||||
needs_upload: bool,
|
vertices_dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageLayer {
|
impl ImageLayer {
|
||||||
@@ -155,9 +165,10 @@ impl ImageLayer {
|
|||||||
texture_layout,
|
texture_layout,
|
||||||
sampler,
|
sampler,
|
||||||
textures: HashMap::new(),
|
textures: HashMap::new(),
|
||||||
|
texture_bytes: 0,
|
||||||
quads: Vec::new(),
|
quads: Vec::new(),
|
||||||
vertex_buffer: None,
|
vertex_buffer: None,
|
||||||
needs_upload: false,
|
vertices_dirty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,45 +176,56 @@ impl ImageLayer {
|
|||||||
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
|
pub fn set_quads(&mut self, quads: Vec<(String, [glam::Vec2; 4])>) {
|
||||||
if quads != self.quads {
|
if quads != self.quads {
|
||||||
self.quads = quads;
|
self.quads = quads;
|
||||||
self.needs_upload = true;
|
self.vertices_dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
/// Lädt fehlende Texturen und den Vertex-Puffer hoch. Vor jedem Zeichnen aufrufen;
|
||||||
/// ohne Änderungen kostet das nichts.
|
/// ohne Änderungen kostet das nichts.
|
||||||
pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, assets: &AssetStore) {
|
pub fn prepare(
|
||||||
if !self.needs_upload {
|
&mut self,
|
||||||
return;
|
device: &wgpu::Device,
|
||||||
}
|
queue: &wgpu::Queue,
|
||||||
self.needs_upload = false;
|
load: &mut dyn FnMut(&str) -> TextureSource,
|
||||||
|
) {
|
||||||
let missing: Vec<String> = self
|
let missing: Vec<String> = self
|
||||||
.quads
|
.quads
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(asset, _)| asset)
|
.map(|(key, _)| key)
|
||||||
.filter(|asset| !self.textures.contains_key(*asset))
|
.filter(|key| !self.textures.contains_key(*key))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
for asset in missing {
|
for key in missing {
|
||||||
if self.textures.contains_key(&asset) {
|
if self.textures.contains_key(&key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let bind_group = match assets.load_rgba(&asset, MAX_DISPLAY_DIMENSION) {
|
let (bind_group, bytes) = match load(&key) {
|
||||||
Ok(image) => self.upload_texture(device, queue, &asset, &image),
|
TextureSource::Ready(image) => (
|
||||||
Err(err) => {
|
self.upload_texture(device, queue, &key, &image),
|
||||||
eprintln!("[Bilder] '{asset}' kann nicht angezeigt werden: {err}");
|
image.pixels.len() as u64 * 4 / 3,
|
||||||
self.missing_texture(device, queue)
|
),
|
||||||
|
// Noch in Arbeit (PDF-Seite wird gerendert): beim nächsten Bild erneut fragen
|
||||||
|
TextureSource::Pending => continue,
|
||||||
|
TextureSource::Failed(err) => {
|
||||||
|
eprintln!("[Bilder] '{key}' kann nicht angezeigt werden: {err}");
|
||||||
|
(self.missing_texture(device, queue), 4)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.textures.insert(asset, bind_group);
|
self.texture_bytes += bytes;
|
||||||
|
self.textures.insert(key, (bind_group, bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nicht mehr benötigte Texturen freigeben, wenn der Speicher voll wird
|
// Nicht mehr benötigte Texturen freigeben, wenn der Grafikspeicher knapp wird
|
||||||
if self.textures.len() > TEXTURE_CACHE_LIMIT {
|
if self.texture_bytes > TEXTURE_CACHE_BYTES {
|
||||||
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
|
let used: HashSet<&String> = self.quads.iter().map(|(a, _)| a).collect();
|
||||||
self.textures.retain(|name, _| used.contains(name));
|
self.textures.retain(|name, _| used.contains(name));
|
||||||
|
self.texture_bytes = self.textures.values().map(|(_, b)| b).sum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.vertices_dirty {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.vertices_dirty = false;
|
||||||
let vertices: Vec<ImageVertex> = self
|
let vertices: Vec<ImageVertex> = self
|
||||||
.quads
|
.quads
|
||||||
.iter()
|
.iter()
|
||||||
@@ -230,18 +252,25 @@ impl ImageLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fehlen noch Texturen der aktuellen Seite (z. B. PDF-Seite im Hintergrund-Rendering)?
|
||||||
|
pub fn is_waiting(&self) -> bool {
|
||||||
|
self.quads
|
||||||
|
.iter()
|
||||||
|
.any(|(key, _)| !self.textures.contains_key(key))
|
||||||
|
}
|
||||||
|
|
||||||
/// Zeichnet die Bilder der aktuellen Seite. Bind Group 0 (Kamera) muss gesetzt sein.
|
/// 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>) {
|
pub fn draw<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) {
|
||||||
let Some(buffer) = &self.vertex_buffer else {
|
let Some(buffer) = &self.vertex_buffer else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.quads.is_empty() || self.needs_upload {
|
if self.quads.is_empty() || self.vertices_dirty {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
render_pass.set_pipeline(&self.pipeline);
|
render_pass.set_pipeline(&self.pipeline);
|
||||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||||
for (i, (asset, _)) in self.quads.iter().enumerate() {
|
for (i, (asset, _)) in self.quads.iter().enumerate() {
|
||||||
let Some(bind_group) = self.textures.get(asset) else {
|
let Some((bind_group, _)) = self.textures.get(asset) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
render_pass.set_bind_group(1, bind_group, &[]);
|
render_pass.set_bind_group(1, bind_group, &[]);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod image_layer;
|
pub mod image_layer;
|
||||||
|
pub mod pdf_doc;
|
||||||
|
pub mod pdf_merge;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
+86
-3
@@ -22,6 +22,8 @@ use ink_notes::canvas::CanvasState;
|
|||||||
use ink_notes::document::{Notebook, PageBackground};
|
use ink_notes::document::{Notebook, PageBackground};
|
||||||
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use ink_notes::geometry::{self, RecognizedShape};
|
use ink_notes::geometry::{self, RecognizedShape};
|
||||||
|
use ink_notes::image_layer::TextureSource;
|
||||||
|
use ink_notes::pdf_doc::PdfRenderService;
|
||||||
use ink_notes::platform::{self, DisplayBackend};
|
use ink_notes::platform::{self, DisplayBackend};
|
||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
@@ -236,6 +238,33 @@ fn insert_image_on_page(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Textur für einen Schlüssel der Bildebene: `<pdf>#<seite>` rendert der Hintergrund-Thread,
|
||||||
|
/// alles andere ist eine Bilddatei im Asset-Ordner.
|
||||||
|
fn texture_for(
|
||||||
|
key: &str,
|
||||||
|
assets: &AssetStore,
|
||||||
|
pdf_renderer: &mut PdfRenderService,
|
||||||
|
) -> TextureSource {
|
||||||
|
let result = match key.split_once('#') {
|
||||||
|
Some((asset, page)) => {
|
||||||
|
let (Ok(path), Ok(page)) = (assets.path_of(asset), page.parse::<usize>()) else {
|
||||||
|
return TextureSource::Failed(format!("Ungültiger PDF-Verweis '{key}'"));
|
||||||
|
};
|
||||||
|
match pdf_renderer.take(key, path, page) {
|
||||||
|
Some(result) => result,
|
||||||
|
None => return TextureSource::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => assets
|
||||||
|
.load_rgba(key, ink_notes::assets::MAX_DISPLAY_DIMENSION)
|
||||||
|
.map_err(|e| e.to_string()),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(image) => TextureSource::Ready(image),
|
||||||
|
Err(err) => TextureSource::Failed(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
|
/// Seitenmitte des sichtbaren Bereichs (Ziel für eingefügte Bilder).
|
||||||
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
fn visible_page_center(canvas: &CanvasState) -> Vec2 {
|
||||||
canvas.screen_to_page_clamped(
|
canvas.screen_to_page_clamped(
|
||||||
@@ -499,6 +528,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
let mut selection_state = SelectionState::new();
|
let mut selection_state = SelectionState::new();
|
||||||
let mut clipboard = ClipboardBridge::new();
|
let mut clipboard = ClipboardBridge::new();
|
||||||
|
// PDF-Seiten werden im Hintergrund gerendert und wecken danach den Event-Loop
|
||||||
|
let mut pdf_renderer = {
|
||||||
|
let window = window.clone();
|
||||||
|
PdfRenderService::new(move || window.request_redraw())
|
||||||
|
};
|
||||||
|
|
||||||
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
||||||
Ok(lock) => {
|
Ok(lock) => {
|
||||||
@@ -972,7 +1006,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
|
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen
|
||||||
WindowEvent::DroppedFile(path) => {
|
WindowEvent::DroppedFile(path) => {
|
||||||
if ink_notes::assets::is_supported_image_path(&path) {
|
if ink_notes::file_picker::is_pdf_path(&path) {
|
||||||
|
ui_state.queued_actions.push(UiAction::ImportPdf(path));
|
||||||
|
} else if ink_notes::assets::is_supported_image_path(&path) {
|
||||||
let (sx, sy) = cursor_screen_pos;
|
let (sx, sy) = cursor_screen_pos;
|
||||||
let center = canvas.screen_to_page_clamped(sx, sy);
|
let center = canvas.screen_to_page_clamped(sx, sy);
|
||||||
insert_image_file(
|
insert_image_file(
|
||||||
@@ -981,7 +1017,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
} else {
|
} else {
|
||||||
ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) können eingefügt werden".to_string());
|
ui_state.set_export_message("Nur Bilder (PNG, JPEG, GIF, WebP, BMP) und PDFs können eingefügt werden".to_string());
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -2426,6 +2462,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::ImportPdf(path) => {
|
||||||
|
let title = path
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "PDF".to_string());
|
||||||
|
let result = std::fs::read(&path)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes));
|
||||||
|
match result {
|
||||||
|
Ok((new_nb, new_manifest, new_dir)) => {
|
||||||
|
let _ = autosave.flush();
|
||||||
|
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||||
|
autosave.set_read_only(_notebook_lock.is_none());
|
||||||
|
autosave.switch_notebook_dir(new_dir.clone());
|
||||||
|
notebook = new_nb;
|
||||||
|
manifest = new_manifest;
|
||||||
|
storage_dir = new_dir;
|
||||||
|
selection_state.clear();
|
||||||
|
ui_state.current_notebook_id = manifest.id.clone();
|
||||||
|
ui_state.document_title = manifest.title.clone();
|
||||||
|
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||||
|
canvas.reset_view();
|
||||||
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
|
ui_state.set_export_message(format!(
|
||||||
|
"'{}' importiert ({} Seiten)",
|
||||||
|
manifest.title,
|
||||||
|
notebook.pages.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[PDF-Import] '{}': {err}", path.display());
|
||||||
|
ui_state.set_export_message(format!("PDF nicht importiert: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::CreateNotebook(title) => {
|
UiAction::CreateNotebook(title) => {
|
||||||
let _ = autosave.flush();
|
let _ = autosave.flush();
|
||||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||||
@@ -2708,7 +2781,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
|
// 5. Canvas (Pass 1) und egui UI (Pass 2) in Surface-Textur rendern
|
||||||
canvas.prepare_images(&AssetStore::new(&storage_dir));
|
let assets = AssetStore::new(&storage_dir);
|
||||||
|
canvas.prepare_images(&mut |key| texture_for(key, &assets, &mut pdf_renderer));
|
||||||
|
// Nächste PDF-Seite vorab rendern, damit Blättern sofort klappt
|
||||||
|
if !canvas.is_waiting_for_images() {
|
||||||
|
let next = notebook.pages.get(notebook.current_page_index + 1);
|
||||||
|
if let Some(pdf) = next.and_then(|p| p.pdf_background.as_ref()) {
|
||||||
|
if let Ok(path) = assets.path_of(&pdf.asset) {
|
||||||
|
pdf_renderer.prefetch(&pdf.key(), path, pdf.page as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
match canvas.render(&clipped_primitives, &screen_descriptor, &full_output.textures_delta) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||||
|
|||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
//! Lesen und Rendern importierter PDF-Dateien mit `hayro` (reines Rust, nur CPU).
|
||||||
|
//!
|
||||||
|
//! Das Rendern einer Seite kann einige hundert Millisekunden dauern; [`PdfRenderService`]
|
||||||
|
//! erledigt es deshalb in einem Hintergrund-Thread und meldet fertige Seiten zurück.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
|
||||||
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
|
use hayro::hayro_syntax::{LoadPdfError, Pdf};
|
||||||
|
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||||
|
use hayro::{PixmapSettings, RenderCache, RenderSettings};
|
||||||
|
|
||||||
|
use crate::assets::RgbaImage;
|
||||||
|
|
||||||
|
/// Längste Kante einer gerenderten Seite für die Anzeige in Pixeln (A4 ≈ 220 dpi).
|
||||||
|
pub const DISPLAY_RENDER_PIXELS: u32 = 2560;
|
||||||
|
/// Größte Seitenzahl, die importiert wird.
|
||||||
|
pub const MAX_IMPORT_PAGES: usize = 2000;
|
||||||
|
|
||||||
|
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
|
||||||
|
/// berücksichtigt).
|
||||||
|
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
|
||||||
|
let pdf = open(bytes)?;
|
||||||
|
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
|
||||||
|
if sizes.is_empty() {
|
||||||
|
return Err("Das PDF enthält keine Seiten".into());
|
||||||
|
}
|
||||||
|
if sizes.len() > MAX_IMPORT_PAGES {
|
||||||
|
return Err(format!(
|
||||||
|
"Das PDF hat {} Seiten, höchstens {MAX_IMPORT_PAGES} werden unterstützt",
|
||||||
|
sizes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(sizes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
|
||||||
|
Pdf::new(bytes).map_err(|e| match e {
|
||||||
|
LoadPdfError::Decryption(_) => {
|
||||||
|
"Das PDF ist verschlüsselt oder passwortgeschützt und kann nicht geöffnet werden"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
LoadPdfError::Invalid => "Die Datei ist kein lesbares PDF".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendert Seite `index` so, dass die längste Kante höchstens `max_pixels` lang ist,
|
||||||
|
/// auf weißem Grund.
|
||||||
|
pub fn render_page(pdf: &Pdf, index: usize, max_pixels: u32) -> Result<RgbaImage, String> {
|
||||||
|
let page = pdf
|
||||||
|
.pages()
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| format!("Seite {} fehlt im PDF", index + 1))?;
|
||||||
|
let (w, h) = page.render_dimensions();
|
||||||
|
let scale = (max_pixels as f32 / w.max(h).max(1.0)).min(16.0);
|
||||||
|
let pixmap = hayro::render(
|
||||||
|
page,
|
||||||
|
&RenderCache::new(),
|
||||||
|
&InterpreterSettings::default(),
|
||||||
|
&RenderSettings::default(),
|
||||||
|
&PixmapSettings {
|
||||||
|
x_scale: scale,
|
||||||
|
y_scale: scale,
|
||||||
|
bg_color: WHITE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Auf weißem Grund ist jedes Pixel deckend: vormultipliziert = gerade Farbe
|
||||||
|
Ok(RgbaImage {
|
||||||
|
width: pixmap.width() as u32,
|
||||||
|
height: pixmap.height() as u32,
|
||||||
|
pixels: pixmap.data_as_u8_slice().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet die Datei und rendert eine Seite (für den PDF-Export als Ersatzbild).
|
||||||
|
pub fn render_file_page(
|
||||||
|
path: &std::path::Path,
|
||||||
|
index: usize,
|
||||||
|
max_pixels: u32,
|
||||||
|
) -> Result<RgbaImage, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
render_page(&open(bytes)?, index, max_pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RenderRequest {
|
||||||
|
key: String,
|
||||||
|
path: PathBuf,
|
||||||
|
page: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ergebnis eines Render-Auftrags.
|
||||||
|
pub type RenderResult = Result<RgbaImage, String>;
|
||||||
|
|
||||||
|
/// Rendert PDF-Seiten im Hintergrund. Fertige Seiten holt der Event-Loop mit
|
||||||
|
/// [`PdfRenderService::take`] ab; `on_ready` weckt ihn dafür auf.
|
||||||
|
pub struct PdfRenderService {
|
||||||
|
requests: Sender<RenderRequest>,
|
||||||
|
results: Receiver<(String, RenderResult)>,
|
||||||
|
pending: HashSet<String>,
|
||||||
|
ready: HashMap<String, RenderResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PdfRenderService {
|
||||||
|
pub fn new(on_ready: impl Fn() + Send + 'static) -> Self {
|
||||||
|
let (req_tx, req_rx) = mpsc::channel::<RenderRequest>();
|
||||||
|
let (res_tx, res_rx) = mpsc::channel();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pdf-render".into())
|
||||||
|
.spawn(move || {
|
||||||
|
// Zuletzt geöffnetes PDF behalten: Blättern rendert meist dieselbe Datei
|
||||||
|
let mut open_pdf: Option<(PathBuf, Pdf)> = None;
|
||||||
|
while let Ok(request) = req_rx.recv() {
|
||||||
|
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
|
||||||
|
open_pdf = std::fs::read(&request.path)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(open)
|
||||||
|
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
|
||||||
|
.ok()
|
||||||
|
.map(|pdf| (request.path.clone(), pdf));
|
||||||
|
}
|
||||||
|
let result = match &open_pdf {
|
||||||
|
Some((_, pdf)) => render_page(pdf, request.page, DISPLAY_RENDER_PIXELS),
|
||||||
|
None => Err("PDF kann nicht geöffnet werden".into()),
|
||||||
|
};
|
||||||
|
if res_tx.send((request.key, result)).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
on_ready();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("PDF-Render-Thread konnte nicht gestartet werden");
|
||||||
|
Self {
|
||||||
|
requests: req_tx,
|
||||||
|
results: res_rx,
|
||||||
|
pending: HashSet::new(),
|
||||||
|
ready: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendert eine Seite vorab (z. B. die nächste), ohne auf das Ergebnis zu warten;
|
||||||
|
/// [`Self::take`] liefert es später sofort.
|
||||||
|
pub fn prefetch(&mut self, key: &str, path: PathBuf, page: usize) {
|
||||||
|
self.collect();
|
||||||
|
if self.ready.contains_key(key) || self.pending.contains(key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vorab gerenderte Seiten nicht unbegrenzt aufheben
|
||||||
|
if self.ready.len() >= 2 {
|
||||||
|
self.ready.clear();
|
||||||
|
}
|
||||||
|
self.request(key, path, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect(&mut self) {
|
||||||
|
while let Ok((done, result)) = self.results.try_recv() {
|
||||||
|
self.pending.remove(&done);
|
||||||
|
self.ready.insert(done, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erteilt den Auftrag, falls noch keiner läuft. `false`, wenn der Thread beendet ist.
|
||||||
|
fn request(&mut self, key: &str, path: PathBuf, page: usize) -> bool {
|
||||||
|
if !self.pending.insert(key.to_string()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let request = RenderRequest {
|
||||||
|
key: key.to_string(),
|
||||||
|
path,
|
||||||
|
page,
|
||||||
|
};
|
||||||
|
if self.requests.send(request).is_err() {
|
||||||
|
self.pending.remove(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fertiges Ergebnis für `key` abholen; sonst den Auftrag (einmalig) erteilen und `None`.
|
||||||
|
pub fn take(&mut self, key: &str, path: PathBuf, page: usize) -> Option<RenderResult> {
|
||||||
|
self.collect();
|
||||||
|
if let Some(result) = self.ready.remove(key) {
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
if !self.request(key, path, page) {
|
||||||
|
return Some(Err("PDF-Render-Thread beendet".into()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
|
||||||
|
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
|
||||||
|
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
|
||||||
|
let mut objects: Vec<String> = Vec::new();
|
||||||
|
let n = pages.len();
|
||||||
|
// 1: Katalog, 2: Seitenbaum, danach je Seite: Seite + Inhalt
|
||||||
|
objects.push("<< /Type /Catalog /Pages 2 0 R >>".into());
|
||||||
|
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
|
||||||
|
objects.push(format!(
|
||||||
|
"<< /Type /Pages /Kids [{}] /Count {n} >>",
|
||||||
|
kids.join(" ")
|
||||||
|
));
|
||||||
|
for (i, (w, h, rotate)) in pages.iter().enumerate() {
|
||||||
|
let content_id = 4 + 2 * i;
|
||||||
|
objects.push(format!(
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Rotate {rotate} /Contents {content_id} 0 R /Resources << >> >>"
|
||||||
|
));
|
||||||
|
let content = format!("1 0 0 rg 0 {} 40 40 re f", h - 40.0);
|
||||||
|
objects.push(format!(
|
||||||
|
"<< /Length {} >>\nstream\n{content}\nendstream",
|
||||||
|
content.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut out = b"%PDF-1.4\n".to_vec();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for (i, obj) in objects.iter().enumerate() {
|
||||||
|
offsets.push(out.len());
|
||||||
|
out.extend_from_slice(format!("{} 0 obj\n{obj}\nendobj\n", i + 1).as_bytes());
|
||||||
|
}
|
||||||
|
let xref_pos = out.len();
|
||||||
|
out.extend_from_slice(
|
||||||
|
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
|
||||||
|
);
|
||||||
|
for off in offsets {
|
||||||
|
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(
|
||||||
|
format!(
|
||||||
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
|
||||||
|
objects.len() + 1
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_page_sizes_and_errors() {
|
||||||
|
let pdf = sample_pdf(&[(595.0, 842.0, 0), (842.0, 595.0, 0), (595.0, 842.0, 90)]);
|
||||||
|
let sizes = page_sizes(pdf).unwrap();
|
||||||
|
assert_eq!(sizes, vec![(595.0, 842.0), (842.0, 595.0), (842.0, 595.0)]);
|
||||||
|
assert!(page_sizes(b"kein pdf".to_vec()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_page_draws_content() {
|
||||||
|
let pdf = open(sample_pdf(&[(200.0, 100.0, 0)])).unwrap();
|
||||||
|
let image = render_page(&pdf, 0, 400).unwrap();
|
||||||
|
assert_eq!((image.width, image.height), (400, 200));
|
||||||
|
let px = |x: u32, y: u32| {
|
||||||
|
let i = ((y * image.width + x) * 4) as usize;
|
||||||
|
&image.pixels[i..i + 4]
|
||||||
|
};
|
||||||
|
// Rotes Quadrat oben links, sonst weiß
|
||||||
|
assert_eq!(px(20, 20), &[255, 0, 0, 255]);
|
||||||
|
assert_eq!(px(300, 150), &[255, 255, 255, 255]);
|
||||||
|
assert!(render_page(&pdf, 5, 400).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_service_delivers_in_background() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("a.pdf");
|
||||||
|
std::fs::write(&path, sample_pdf(&[(400.0, 300.0, 0)])).unwrap();
|
||||||
|
let (wake_tx, wake_rx) = mpsc::channel();
|
||||||
|
let mut service = PdfRenderService::new(move || {
|
||||||
|
let _ = wake_tx.send(());
|
||||||
|
});
|
||||||
|
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||||
|
// Zweite Anfrage erteilt keinen weiteren Auftrag
|
||||||
|
assert!(service.take("a#0", path.clone(), 0).is_none());
|
||||||
|
wake_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.unwrap();
|
||||||
|
let image = service.take("a#0", path.clone(), 0).unwrap().unwrap();
|
||||||
|
assert_eq!(image.width, DISPLAY_RENDER_PIXELS);
|
||||||
|
|
||||||
|
// Vorab rendern: danach sofort verfügbar
|
||||||
|
service.prefetch("a#0", path.clone(), 0);
|
||||||
|
wake_rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.unwrap();
|
||||||
|
assert!(service.take("a#0", path, 0).unwrap().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
|
||||||
|
//! Notizen (Text bleibt scharf und auswählbar).
|
||||||
|
//!
|
||||||
|
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
|
||||||
|
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
|
||||||
|
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
|
||||||
|
|
||||||
|
/// Eine Zielseite, unter die eine Seite eines Quell-PDFs gelegt wird.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Underlay {
|
||||||
|
/// Seitennummer im Ziel-PDF, ab 0
|
||||||
|
pub target_page: usize,
|
||||||
|
/// Schlüssel des Quell-PDFs in `sources`
|
||||||
|
pub source: String,
|
||||||
|
/// Seitennummer im Quell-PDF, ab 0
|
||||||
|
pub source_page: usize,
|
||||||
|
/// Zielrechteck in PDF-Punkten (links, unten, Breite, Höhe; Ursprung unten links)
|
||||||
|
pub rect: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nicht übernommene Unterlagen samt Grund.
|
||||||
|
pub type FailedUnderlays = Vec<(Underlay, String)>;
|
||||||
|
|
||||||
|
/// Affine PDF-Matrix `[a b c d e f]`: x' = a·x + c·y + e, y' = b·x + d·y + f.
|
||||||
|
pub type Matrix = [f32; 6];
|
||||||
|
|
||||||
|
/// `outer ∘ inner`: erst `inner`, dann `outer` anwenden.
|
||||||
|
pub fn compose(outer: Matrix, inner: Matrix) -> Matrix {
|
||||||
|
let [a1, b1, c1, d1, e1, f1] = outer;
|
||||||
|
let [a2, b2, c2, d2, e2, f2] = inner;
|
||||||
|
[
|
||||||
|
a1 * a2 + c1 * b2,
|
||||||
|
b1 * a2 + d1 * b2,
|
||||||
|
a1 * c2 + c1 * d2,
|
||||||
|
b1 * c2 + d1 * d2,
|
||||||
|
a1 * e2 + c1 * f2 + e1,
|
||||||
|
b1 * e2 + d1 * f2 + f1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matrix, die den Seitenbereich `bbox` (links, unten, rechts, oben) einer um `rotate` Grad
|
||||||
|
/// im Uhrzeigersinn gedrehten Seite so abbildet, wie ein Betrachter sie zeigt, eingepasst
|
||||||
|
/// in `rect` (links, unten, Breite, Höhe).
|
||||||
|
pub fn placement_matrix(bbox: [f32; 4], rotate: i64, rect: [f32; 4]) -> Matrix {
|
||||||
|
let (w, h) = ((bbox[2] - bbox[0]).max(1e-3), (bbox[3] - bbox[1]).max(1e-3));
|
||||||
|
let to_origin = [1.0, 0.0, 0.0, 1.0, -bbox[0], -bbox[1]];
|
||||||
|
let (rotation, shown_w, shown_h): (Matrix, f32, f32) = match rotate.rem_euclid(360) {
|
||||||
|
90 => ([0.0, -1.0, 1.0, 0.0, 0.0, w], h, w),
|
||||||
|
180 => ([-1.0, 0.0, 0.0, -1.0, w, h], w, h),
|
||||||
|
270 => ([0.0, 1.0, -1.0, 0.0, h, 0.0], h, w),
|
||||||
|
_ => ([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], w, h),
|
||||||
|
};
|
||||||
|
let scale = (rect[2] / shown_w).min(rect[3] / shown_h);
|
||||||
|
let place = [scale, 0.0, 0.0, scale, rect[0], rect[1]];
|
||||||
|
compose(place, compose(rotation, to_origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erbt ein Seitenattribut über die `Parent`-Kette.
|
||||||
|
fn inherited<'a>(doc: &'a Document, page: &'a Dictionary, key: &[u8]) -> Option<&'a Object> {
|
||||||
|
let mut node = page;
|
||||||
|
for _ in 0..64 {
|
||||||
|
if let Ok(value) = node.get(key) {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
let parent = node.get(b"Parent").and_then(Object::as_reference).ok()?;
|
||||||
|
node = doc.get_dictionary(parent).ok()?;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve<'a>(doc: &'a Document, object: &'a Object) -> &'a Object {
|
||||||
|
doc.dereference(object).map(|(_, o)| o).unwrap_or(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_box(doc: &Document, object: Option<&Object>) -> Option<[f32; 4]> {
|
||||||
|
let array = resolve(doc, object?).as_array().ok()?;
|
||||||
|
let values: Vec<f32> = array
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| resolve(doc, v).as_float().ok())
|
||||||
|
.collect();
|
||||||
|
let [x0, y0, x1, y1] = <[f32; 4]>::try_from(values).ok()?;
|
||||||
|
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut aus einer Seite des (bereits umnummerierten) Quelldokuments ein Form-XObject.
|
||||||
|
/// Liefert das XObject und die Matrix für die Platzierung in `rect`.
|
||||||
|
fn page_as_form(
|
||||||
|
doc: &Document,
|
||||||
|
page_id: ObjectId,
|
||||||
|
rect: [f32; 4],
|
||||||
|
) -> Result<(Stream, Matrix), String> {
|
||||||
|
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
|
||||||
|
let media =
|
||||||
|
read_box(doc, inherited(doc, page, b"MediaBox")).unwrap_or([0.0, 0.0, 595.0, 842.0]);
|
||||||
|
let bbox = match read_box(doc, inherited(doc, page, b"CropBox")) {
|
||||||
|
// Sichtbarer Bereich: Schnitt aus Crop- und Media-Box
|
||||||
|
Some(c) => [
|
||||||
|
c[0].max(media[0]),
|
||||||
|
c[1].max(media[1]),
|
||||||
|
c[2].min(media[2]),
|
||||||
|
c[3].min(media[3]),
|
||||||
|
],
|
||||||
|
None => media,
|
||||||
|
};
|
||||||
|
let rotate = inherited(doc, page, b"Rotate")
|
||||||
|
.and_then(|r| resolve(doc, r).as_i64().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let resources = inherited(doc, page, b"Resources")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| Object::Dictionary(Dictionary::new()));
|
||||||
|
|
||||||
|
// Ein einzelner Inhaltsstrom wird unverändert (samt Kompression) übernommen,
|
||||||
|
// mehrere werden entpackt und aneinandergehängt
|
||||||
|
let streams = doc.get_page_contents(page_id);
|
||||||
|
let (content, filter) = match streams.as_slice() {
|
||||||
|
[single] => {
|
||||||
|
let stream = doc
|
||||||
|
.get_object(*single)
|
||||||
|
.and_then(Object::as_stream)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let filter = ["Filter", "DecodeParms"]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|k| Some((k, stream.dict.get(k.as_bytes()).ok()?.clone())))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(stream.content.clone(), filter)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut content = Vec::new();
|
||||||
|
for id in streams {
|
||||||
|
let stream = doc
|
||||||
|
.get_object(id)
|
||||||
|
.and_then(Object::as_stream)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let data = stream
|
||||||
|
.decompressed_content()
|
||||||
|
.map_err(|e| format!("Inhalt nicht lesbar: {e}"))?;
|
||||||
|
content.extend_from_slice(&data);
|
||||||
|
content.push(b'\n');
|
||||||
|
}
|
||||||
|
(content, Vec::new())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut dict = Dictionary::new();
|
||||||
|
dict.set("Type", Object::Name(b"XObject".to_vec()));
|
||||||
|
dict.set("Subtype", Object::Name(b"Form".to_vec()));
|
||||||
|
dict.set(
|
||||||
|
"BBox",
|
||||||
|
Object::Array(bbox.iter().map(|&v| Object::Real(v)).collect()),
|
||||||
|
);
|
||||||
|
dict.set("Resources", resources);
|
||||||
|
for (key, value) in filter {
|
||||||
|
dict.set(key, value);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
Stream::new(dict, content),
|
||||||
|
placement_matrix(bbox, rotate, rect),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt die Quellseiten unter die Zielseiten. `sources` enthält die Quell-PDFs als Bytes.
|
||||||
|
/// Gibt die neuen PDF-Bytes zurück und die Underlays, die nicht übernommen werden konnten
|
||||||
|
/// (z. B. verschlüsselte PDFs), samt Grund.
|
||||||
|
pub fn apply_underlays(
|
||||||
|
target_bytes: &[u8],
|
||||||
|
underlays: &[Underlay],
|
||||||
|
sources: &HashMap<String, Vec<u8>>,
|
||||||
|
) -> Result<(Vec<u8>, FailedUnderlays), String> {
|
||||||
|
let mut target = Document::load_mem(target_bytes).map_err(|e| e.to_string())?;
|
||||||
|
let target_pages: Vec<ObjectId> = target.get_pages().into_values().collect();
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
|
||||||
|
// Quell-PDFs einmal laden und mit freien Objektnummern in das Ziel übernehmen
|
||||||
|
let mut loaded: HashMap<&str, Result<BTreeMap<u32, ObjectId>, String>> = HashMap::new();
|
||||||
|
for underlay in underlays {
|
||||||
|
if loaded.contains_key(underlay.source.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let result = (|| {
|
||||||
|
let bytes = sources
|
||||||
|
.get(&underlay.source)
|
||||||
|
.ok_or_else(|| "Quell-PDF fehlt".to_string())?;
|
||||||
|
let mut source = Document::load_mem(bytes).map_err(|e| e.to_string())?;
|
||||||
|
if source.is_encrypted() {
|
||||||
|
return Err("Quell-PDF ist verschlüsselt".to_string());
|
||||||
|
}
|
||||||
|
source.renumber_objects_with(target.max_id + 1);
|
||||||
|
let pages = source.get_pages();
|
||||||
|
target.max_id = target.max_id.max(source.max_id);
|
||||||
|
// Katalog und Seitenbaum der Quelle werden nicht referenziert und beim Speichern
|
||||||
|
// mit `prune_objects` entfernt
|
||||||
|
target.objects.extend(source.objects);
|
||||||
|
Ok(pages)
|
||||||
|
})();
|
||||||
|
loaded.insert(&underlay.source, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
for underlay in underlays {
|
||||||
|
let result = (|| {
|
||||||
|
let pages = loaded[underlay.source.as_str()]
|
||||||
|
.as_ref()
|
||||||
|
.map_err(Clone::clone)?;
|
||||||
|
let source_page = *pages
|
||||||
|
.get(&(underlay.source_page as u32 + 1))
|
||||||
|
.ok_or_else(|| format!("Seite {} fehlt im Quell-PDF", underlay.source_page + 1))?;
|
||||||
|
let target_page = *target_pages
|
||||||
|
.get(underlay.target_page)
|
||||||
|
.ok_or_else(|| "Zielseite fehlt".to_string())?;
|
||||||
|
let (form, matrix) = page_as_form(&target, source_page, underlay.rect)?;
|
||||||
|
let form_id = target.add_object(Object::Stream(form));
|
||||||
|
let name = format!("InkPdf{}", form_id.0);
|
||||||
|
add_xobject(&mut target, target_page, &name, form_id)?;
|
||||||
|
let ops = format!(
|
||||||
|
"q {} {} {} {} {} {} cm /{name} Do Q\n",
|
||||||
|
matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]
|
||||||
|
);
|
||||||
|
prepend_content(&mut target, target_page, ops.into_bytes())
|
||||||
|
})();
|
||||||
|
if let Err(err) = result {
|
||||||
|
failed.push((underlay.clone(), err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.prune_objects();
|
||||||
|
// Neue, noch unkomprimierte Ströme packen (bereits gefilterte bleiben unverändert)
|
||||||
|
target.compress();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
target.save_to(&mut out).map_err(|e| e.to_string())?;
|
||||||
|
Ok((out, failed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trägt ein XObject in die Ressourcen der Seite ein.
|
||||||
|
fn add_xobject(
|
||||||
|
doc: &mut Document,
|
||||||
|
page_id: ObjectId,
|
||||||
|
name: &str,
|
||||||
|
id: ObjectId,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Ressourcen der Seite als eigenes Wörterbuch an der Seite (geerbte werden übernommen)
|
||||||
|
let resources = {
|
||||||
|
let page = doc.get_dictionary(page_id).map_err(|e| e.to_string())?;
|
||||||
|
match inherited(doc, page, b"Resources").map(|r| resolve(doc, r)) {
|
||||||
|
Some(Object::Dictionary(d)) => d.clone(),
|
||||||
|
_ => Dictionary::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut xobjects = match resources.get(b"XObject").map(|x| resolve(doc, x)) {
|
||||||
|
Ok(Object::Dictionary(d)) => d.clone(),
|
||||||
|
_ => Dictionary::new(),
|
||||||
|
};
|
||||||
|
xobjects.set(name, Object::Reference(id));
|
||||||
|
let mut resources = resources;
|
||||||
|
resources.set("XObject", Object::Dictionary(xobjects));
|
||||||
|
let page = doc
|
||||||
|
.get_object_mut(page_id)
|
||||||
|
.and_then(Object::as_dict_mut)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
page.set("Resources", Object::Dictionary(resources));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stellt einen Inhaltsstrom an den Anfang der Seite (zeichnet also zuunterst).
|
||||||
|
fn prepend_content(doc: &mut Document, page_id: ObjectId, content: Vec<u8>) -> Result<(), String> {
|
||||||
|
let existing: Vec<Object> = doc
|
||||||
|
.get_page_contents(page_id)
|
||||||
|
.into_iter()
|
||||||
|
.map(Object::Reference)
|
||||||
|
.collect();
|
||||||
|
let id = doc.add_object(Object::Stream(Stream::new(Dictionary::new(), content)));
|
||||||
|
let mut contents = vec![Object::Reference(id)];
|
||||||
|
contents.extend(existing);
|
||||||
|
let page = doc
|
||||||
|
.get_object_mut(page_id)
|
||||||
|
.and_then(Object::as_dict_mut)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
page.set("Contents", Object::Array(contents));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn apply(m: Matrix, x: f32, y: f32) -> (f32, f32) {
|
||||||
|
(m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(a: (f32, f32), b: (f32, f32)) -> bool {
|
||||||
|
(a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_placement_matrix() {
|
||||||
|
// Querformat-Folie 800 × 600 ohne Drehung in ein 400 × 300-Rechteck bei (10, 20)
|
||||||
|
let m = placement_matrix([0.0, 0.0, 800.0, 600.0], 0, [10.0, 20.0, 400.0, 300.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (10.0, 20.0)));
|
||||||
|
assert!(close(apply(m, 800.0, 600.0), (410.0, 320.0)));
|
||||||
|
|
||||||
|
// Verschobene MediaBox
|
||||||
|
let m = placement_matrix([100.0, 50.0, 300.0, 150.0], 0, [0.0, 0.0, 200.0, 100.0]);
|
||||||
|
assert!(close(apply(m, 100.0, 50.0), (0.0, 0.0)));
|
||||||
|
|
||||||
|
// Um 90° gedrehte Hochformatseite (200 × 400) erscheint quer (400 × 200):
|
||||||
|
// die linke untere Ecke landet links oben
|
||||||
|
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 90, [0.0, 0.0, 400.0, 200.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (0.0, 200.0)));
|
||||||
|
assert!(close(apply(m, 200.0, 0.0), (0.0, 0.0)));
|
||||||
|
assert!(close(apply(m, 0.0, 400.0), (400.0, 200.0)));
|
||||||
|
|
||||||
|
// 180° und 270° (= -90°)
|
||||||
|
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], 180, [0.0, 0.0, 200.0, 400.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (200.0, 400.0)));
|
||||||
|
let m = placement_matrix([0.0, 0.0, 200.0, 400.0], -90, [0.0, 0.0, 400.0, 200.0]);
|
||||||
|
assert!(close(apply(m, 0.0, 0.0), (400.0, 0.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_underlays_embeds_source_pages() {
|
||||||
|
let source = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
|
||||||
|
let target = crate::pdf_doc::tests::sample_pdf(&[(595.0, 842.0, 0), (595.0, 842.0, 0)]);
|
||||||
|
let sources = HashMap::from([("folien".to_string(), source)]);
|
||||||
|
let underlays = vec![
|
||||||
|
Underlay {
|
||||||
|
target_page: 0,
|
||||||
|
source: "folien".into(),
|
||||||
|
source_page: 0,
|
||||||
|
rect: [0.0, 421.0, 595.0, 421.0],
|
||||||
|
},
|
||||||
|
Underlay {
|
||||||
|
target_page: 1,
|
||||||
|
source: "folien".into(),
|
||||||
|
source_page: 5,
|
||||||
|
rect: [0.0, 0.0, 595.0, 842.0],
|
||||||
|
},
|
||||||
|
Underlay {
|
||||||
|
target_page: 1,
|
||||||
|
source: "fehlt".into(),
|
||||||
|
source_page: 0,
|
||||||
|
rect: [0.0, 0.0, 595.0, 842.0],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let (bytes, failed) = apply_underlays(&target, &underlays, &sources).unwrap();
|
||||||
|
// Fehlende Quellseite und fehlendes PDF werden gemeldet, der Rest klappt
|
||||||
|
assert_eq!(failed.len(), 2);
|
||||||
|
|
||||||
|
let doc = Document::load_mem(&bytes).unwrap();
|
||||||
|
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
|
||||||
|
assert_eq!(
|
||||||
|
pages.len(),
|
||||||
|
2,
|
||||||
|
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
|
||||||
|
);
|
||||||
|
let first = doc.get_page_content(pages[0]).unwrap();
|
||||||
|
let text = String::from_utf8_lossy(&first);
|
||||||
|
assert!(
|
||||||
|
text.starts_with("q "),
|
||||||
|
"Unterlage muss zuerst gezeichnet werden: {text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("Do Q"));
|
||||||
|
// Ursprünglicher Seiteninhalt bleibt erhalten
|
||||||
|
assert!(text.contains("re f"));
|
||||||
|
|
||||||
|
// Form-XObject mit dem Inhalt der Folie ist in den Ressourcen eingetragen
|
||||||
|
let page = doc.get_dictionary(pages[0]).unwrap();
|
||||||
|
let resources = page.get(b"Resources").unwrap().as_dict().unwrap();
|
||||||
|
let xobjects = resources.get(b"XObject").unwrap().as_dict().unwrap();
|
||||||
|
let (_, form_ref) = xobjects.iter().next().unwrap();
|
||||||
|
let form = doc
|
||||||
|
.get_object(form_ref.as_reference().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.as_stream()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
form.dict.get(b"Subtype").unwrap().as_name().unwrap(),
|
||||||
|
b"Form"
|
||||||
|
);
|
||||||
|
assert!(String::from_utf8_lossy(&form.content).contains("1 0 0 rg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-2
@@ -151,7 +151,8 @@ impl StrokeData {
|
|||||||
/// - Version 4: eingefügte Bilder (`image` am Strich, Ordner `assets/`). Ältere Versionen würden
|
/// - Version 4: eingefügte Bilder (`image` am Strich, Ordner `assets/`). Ältere Versionen würden
|
||||||
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
|
/// das unbekannte Feld ignorieren, Bilder als Rechteckstrich anzeigen und beim Speichern
|
||||||
/// verlieren.
|
/// verlieren.
|
||||||
pub const CURRENT_FORMAT_VERSION: u32 = 4;
|
/// - Version 5: importierte PDF-Seiten als Seitenhintergrund (`pdf_background`).
|
||||||
|
pub const CURRENT_FORMAT_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0).
|
||||||
pub fn legacy_format_version() -> u32 {
|
pub fn legacy_format_version() -> u32 {
|
||||||
@@ -165,6 +166,9 @@ pub struct PageData {
|
|||||||
pub format_version: u32,
|
pub format_version: u32,
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub background: PageBackground,
|
pub background: PageBackground,
|
||||||
|
/// Importierte PDF-Seite als Hintergrund
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pdf_background: Option<crate::document::PdfBackground>,
|
||||||
pub strokes: Vec<StrokeData>,
|
pub strokes: Vec<StrokeData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +178,7 @@ impl From<&Page> for PageData {
|
|||||||
format_version: CURRENT_FORMAT_VERSION,
|
format_version: CURRENT_FORMAT_VERSION,
|
||||||
id: page.id,
|
id: page.id,
|
||||||
background: page.background,
|
background: page.background,
|
||||||
|
pdf_background: page.pdf_background.clone(),
|
||||||
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
strokes: page.strokes.iter().map(StrokeData::from).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +188,7 @@ impl PageData {
|
|||||||
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
|
/// Wandelt die Seitendaten in eine runtime-aktive `Page` mit initialisiertem Cache um.
|
||||||
pub fn to_page(&self) -> Page {
|
pub fn to_page(&self) -> Page {
|
||||||
let mut page = Page::new(self.id, self.background);
|
let mut page = Page::new(self.id, self.background);
|
||||||
|
page.pdf_background = self.pdf_background.clone();
|
||||||
for stroke_data in &self.strokes {
|
for stroke_data in &self.strokes {
|
||||||
page.strokes.push(stroke_data.to_stroke());
|
page.strokes.push(stroke_data.to_stroke());
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1062,50 @@ pub fn create_new_notebook(
|
|||||||
Ok((notebook, manifest, notebook_dir))
|
Ok((notebook, manifest, notebook_dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legt ein neues Notizbuch aus einem PDF an: eine Seite je PDF-Seite, die PDF-Seite als
|
||||||
|
/// Hintergrund. Das PDF wird unverändert im Asset-Ordner abgelegt; alle Seiten werden sofort
|
||||||
|
/// gespeichert.
|
||||||
|
pub fn create_notebook_from_pdf(
|
||||||
|
base_dir: &Path,
|
||||||
|
title: &str,
|
||||||
|
pdf_bytes: Vec<u8>,
|
||||||
|
) -> Result<(Notebook, NotebookManifest, PathBuf), String> {
|
||||||
|
let sizes = crate::pdf_doc::page_sizes(pdf_bytes.clone())?;
|
||||||
|
let (_, _, dir) = create_new_notebook(base_dir, title).map_err(|e| e.to_string())?;
|
||||||
|
let result = (|| {
|
||||||
|
let asset = crate::assets::AssetStore::new(&dir)
|
||||||
|
.import_pdf(&pdf_bytes)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let pages: Vec<Page> = sizes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &(width_pt, height_pt))| {
|
||||||
|
let mut page = Page::new(i as u64 + 1, PageBackground::Blank);
|
||||||
|
page.pdf_background = Some(crate::document::PdfBackground {
|
||||||
|
asset: asset.clone(),
|
||||||
|
page: i as u32,
|
||||||
|
width_pt,
|
||||||
|
height_pt,
|
||||||
|
});
|
||||||
|
page
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let page_count = pages.len() as u64;
|
||||||
|
let notebook = Notebook::from_pages(pages, 0, 1, page_count + 1);
|
||||||
|
let mut manifest = load_manifest(&dir).map_err(|e| e.to_string())?;
|
||||||
|
save_notebook(&dir, ¬ebook, &mut manifest).map_err(|e| e.to_string())?;
|
||||||
|
Ok((notebook, manifest))
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok((notebook, manifest)) => Ok((notebook, manifest, dir)),
|
||||||
|
Err(err) => {
|
||||||
|
// Halbfertiges Notizbuch nicht liegen lassen
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
|
/// Benennt ein Notizbuch um und aktualisiert das Manifest atomar auf der Festplatte.
|
||||||
pub fn rename_notebook(
|
pub fn rename_notebook(
|
||||||
notebook_dir: &Path,
|
notebook_dir: &Path,
|
||||||
@@ -2354,7 +2404,10 @@ mod tests {
|
|||||||
corners,
|
corners,
|
||||||
));
|
));
|
||||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||||
assert_eq!(load_manifest(&dir).unwrap().format_version, 4);
|
assert_eq!(
|
||||||
|
load_manifest(&dir).unwrap().format_version,
|
||||||
|
CURRENT_FORMAT_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
let (reloaded, _) = load_notebook(&dir).unwrap();
|
let (reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
let image = &reloaded.current_page().strokes[0];
|
let image = &reloaded.current_page().strokes[0];
|
||||||
@@ -2364,4 +2417,36 @@ mod tests {
|
|||||||
// Bilddatei liegt im Notizbuchordner
|
// Bilddatei liegt im Notizbuchordner
|
||||||
assert!(dir.join("assets").join(&imported.asset).exists());
|
assert!(dir.join("assets").join(&imported.asset).exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_notebook_from_pdf() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let pdf = crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (595.0, 842.0, 0)]);
|
||||||
|
let (notebook, manifest, dir) =
|
||||||
|
create_notebook_from_pdf(temp_dir.path(), "Vorlesung 3", pdf.clone()).unwrap();
|
||||||
|
assert_eq!(manifest.title, "Vorlesung 3");
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
assert_eq!(notebook.next_page_id, 3);
|
||||||
|
|
||||||
|
// Nach dem Neuladen: jede Seite verweist auf ihre PDF-Seite, das PDF liegt unverändert
|
||||||
|
// im Asset-Ordner
|
||||||
|
let (mut reloaded, _) = load_notebook(&dir).unwrap();
|
||||||
|
load_all_pages(&dir, &mut reloaded).unwrap();
|
||||||
|
for (i, page) in reloaded.pages.iter().enumerate() {
|
||||||
|
let bg = page.pdf_background.as_ref().expect("PDF-Hintergrund fehlt");
|
||||||
|
assert_eq!(bg.page, i as u32);
|
||||||
|
assert_eq!(page.background, PageBackground::Blank);
|
||||||
|
}
|
||||||
|
let first = reloaded.pages[0].pdf_background.clone().unwrap();
|
||||||
|
assert_eq!((first.width_pt, first.height_pt), (842.0, 595.0));
|
||||||
|
let assets = crate::assets::AssetStore::new(&dir);
|
||||||
|
assert_eq!(assets.read_bytes(&first.asset).unwrap(), pdf);
|
||||||
|
|
||||||
|
// Kein PDF: Fehler und kein halbfertiges Notizbuch
|
||||||
|
let before = std::fs::read_dir(temp_dir.path()).unwrap().count();
|
||||||
|
assert!(
|
||||||
|
create_notebook_from_pdf(temp_dir.path(), "Kaputt", b"%PDF-kaputt".to_vec()).is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), before);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::background::{adapt_ink_color, PaperTheme};
|
|||||||
use crate::document::{
|
use crate::document::{
|
||||||
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH,
|
||||||
};
|
};
|
||||||
use crate::file_picker::FilePicker;
|
use crate::file_picker::{FilePicker, PickerKind};
|
||||||
use crate::settings::{PressureCurve, SidebarSide};
|
use crate::settings::{PressureCurve, SidebarSide};
|
||||||
use crate::storage::NotebookSummary;
|
use crate::storage::NotebookSummary;
|
||||||
|
|
||||||
@@ -154,6 +154,8 @@ pub enum UiAction {
|
|||||||
SavePressureCurve(PressureCurve),
|
SavePressureCurve(PressureCurve),
|
||||||
/// Bilddatei auf der aktuellen Seite einfügen
|
/// Bilddatei auf der aktuellen Seite einfügen
|
||||||
InsertImageFile(std::path::PathBuf),
|
InsertImageFile(std::path::PathBuf),
|
||||||
|
/// PDF als neues Notizbuch importieren
|
||||||
|
ImportPdf(std::path::PathBuf),
|
||||||
// Beenden-Sicherheit bei Fehlschlag
|
// Beenden-Sicherheit bei Fehlschlag
|
||||||
RetryShutdown,
|
RetryShutdown,
|
||||||
ExportRecoveryAndExit,
|
ExportRecoveryAndExit,
|
||||||
@@ -215,10 +217,13 @@ pub struct UiState {
|
|||||||
pub pressure_curve: PressureCurve,
|
pub pressure_curve: PressureCurve,
|
||||||
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
/// Zuletzt gemessener Rohdruck des Stifts (Anzeige in der Druckkurve)
|
||||||
pub last_raw_pressure: Option<f32>,
|
pub last_raw_pressure: Option<f32>,
|
||||||
/// Geöffnete Dateiauswahl „Bild einfügen“
|
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||||
pub image_picker: Option<FilePicker>,
|
pub file_picker: Option<FilePicker>,
|
||||||
|
/// Aktionen, die außerhalb der Oberfläche ausgelöst wurden (z. B. ins Fenster gezogene
|
||||||
|
/// PDF-Datei); `render_ui` gibt sie beim nächsten Bild mit aus
|
||||||
|
pub queued_actions: Vec<UiAction>,
|
||||||
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||||||
pub last_image_dir: Option<std::path::PathBuf>,
|
pub last_picker_dir: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UiState {
|
impl Default for UiState {
|
||||||
@@ -259,8 +264,9 @@ impl Default for UiState {
|
|||||||
adapt_ink_colors: true,
|
adapt_ink_colors: true,
|
||||||
pressure_curve: PressureCurve::default(),
|
pressure_curve: PressureCurve::default(),
|
||||||
last_raw_pressure: None,
|
last_raw_pressure: None,
|
||||||
image_picker: None,
|
file_picker: None,
|
||||||
last_image_dir: None,
|
queued_actions: Vec::new(),
|
||||||
|
last_picker_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,14 +288,23 @@ impl UiState {
|
|||||||
self.show_page_jump_popup = false;
|
self.show_page_jump_popup = false;
|
||||||
self.is_document_menu_open = false;
|
self.is_document_menu_open = false;
|
||||||
self.is_update_dialog_open = false;
|
self.is_update_dialog_open = false;
|
||||||
self.image_picker = None;
|
self.file_picker = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
/// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner.
|
||||||
pub fn open_image_picker(&mut self) {
|
pub fn open_image_picker(&mut self) {
|
||||||
let dir = self.last_image_dir.clone();
|
self.open_file_picker(PickerKind::Image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Öffnet die Dateiauswahl „PDF importieren“ im zuletzt benutzten Ordner.
|
||||||
|
pub fn open_pdf_picker(&mut self) {
|
||||||
|
self.open_file_picker(PickerKind::Pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||||
|
let dir = self.last_picker_dir.clone();
|
||||||
self.close_overlays();
|
self.close_overlays();
|
||||||
self.image_picker = Some(FilePicker::open(dir.as_deref()));
|
self.file_picker = Some(FilePicker::open(kind, dir.as_deref()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
|
/// Länge der längsten Farbpalette; so viel Platz hält die Leiste immer frei.
|
||||||
@@ -775,6 +790,16 @@ pub fn page_thumbnail_shapes(
|
|||||||
let scale = rect.width() / PAGE_WIDTH;
|
let scale = rect.width() / PAGE_WIDTH;
|
||||||
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
let to_screen = |p: glam::Vec2| rect.min + egui::vec2(p.x * scale, p.y * scale);
|
||||||
|
|
||||||
|
// Importierte PDF-Seite als helle Fläche mit Rand
|
||||||
|
if let Some(pdf) = &page.pdf_background {
|
||||||
|
let points: Vec<egui::Pos2> = pdf.corners().iter().map(|&c| to_screen(c)).collect();
|
||||||
|
shapes.push(egui::Shape::convex_polygon(
|
||||||
|
points,
|
||||||
|
egui::Color32::from_rgb(236, 238, 243),
|
||||||
|
egui::Stroke::new(0.8_f32, egui::Color32::from_gray(150)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Bilder als Flächen unter der Tinte
|
// Bilder als Flächen unter der Tinte
|
||||||
for corners in page.strokes.iter().filter_map(|s| s.image_corners()) {
|
for corners in page.strokes.iter().filter_map(|s| s.image_corners()) {
|
||||||
shapes.push(egui::Shape::convex_polygon(
|
shapes.push(egui::Shape::convex_polygon(
|
||||||
@@ -979,20 +1004,39 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option<Press
|
|||||||
save.then_some(state.pressure_curve)
|
save.then_some(state.pressure_curve)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dialog „Bild einfügen“: Schnellzugriffe, Ordnerpfad und Liste aus Ordnern und Bildern.
|
/// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus
|
||||||
fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
/// Ordnern und passenden Dateien.
|
||||||
|
fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec<UiAction>) {
|
||||||
let mut close = false;
|
let mut close = false;
|
||||||
let mut navigate: Option<std::path::PathBuf> = None;
|
let mut navigate: Option<std::path::PathBuf> = None;
|
||||||
let mut go_up = false;
|
let mut go_up = false;
|
||||||
let mut chosen: Option<std::path::PathBuf> = None;
|
let mut chosen: Option<std::path::PathBuf> = None;
|
||||||
|
|
||||||
let Some(picker) = state.image_picker.as_ref() else {
|
let Some(picker) = state.file_picker.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
dialog_window("Bild einfügen", state, false).show(ctx, |ui| {
|
let picker_kind = picker.kind;
|
||||||
|
let (title, header, file_icon, empty_hint, tip) = match picker.kind {
|
||||||
|
PickerKind::Image => (
|
||||||
|
"Bild einfügen",
|
||||||
|
"🖼 Bild einfügen",
|
||||||
|
"🖼",
|
||||||
|
"Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)",
|
||||||
|
"Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.",
|
||||||
|
),
|
||||||
|
PickerKind::Pdf => (
|
||||||
|
"PDF importieren",
|
||||||
|
"📄 PDF importieren",
|
||||||
|
"📄",
|
||||||
|
"Keine Ordner oder PDF-Dateien",
|
||||||
|
"Das PDF wird als neues Notizbuch angelegt, eine Seite je PDF-Seite. \
|
||||||
|
Beim Export liegen deine Notizen über den Originalseiten.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
dialog_window(title, state, false).show(ctx, |ui| {
|
||||||
ui.set_width(460.0);
|
ui.set_width(460.0);
|
||||||
touch_friendly_spacing(ui);
|
touch_friendly_spacing(ui);
|
||||||
if dialog_header(ui, "🖼 Bild einfügen", true) {
|
if dialog_header(ui, header, true) {
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,16 +1074,13 @@ fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut V
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
if picker.entries.is_empty() {
|
if picker.entries.is_empty() {
|
||||||
ui.label(
|
ui.label(egui::RichText::new(empty_hint).color(egui::Color32::from_gray(150)));
|
||||||
egui::RichText::new("Keine Ordner oder Bilder (PNG, JPEG, GIF, WebP, BMP)")
|
|
||||||
.color(egui::Color32::from_gray(150)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
for entry in &picker.entries {
|
for entry in &picker.entries {
|
||||||
let label = if entry.is_dir {
|
let label = if entry.is_dir {
|
||||||
format!("📁 {}", entry.name)
|
format!("📁 {}", entry.name)
|
||||||
} else {
|
} else {
|
||||||
format!("🖼 {}", entry.name)
|
format!("{file_icon} {}", entry.name)
|
||||||
};
|
};
|
||||||
let button = egui::Button::new(label)
|
let button = egui::Button::new(label)
|
||||||
.frame(false)
|
.frame(false)
|
||||||
@@ -1057,15 +1098,13 @@ fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut V
|
|||||||
|
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(
|
egui::RichText::new(tip)
|
||||||
"Tipp: Screenshots mit Strg+V einfügen oder Bilddateien ins Fenster ziehen.",
|
|
||||||
)
|
|
||||||
.size(11.5)
|
.size(11.5)
|
||||||
.color(egui::Color32::from_gray(150)),
|
.color(egui::Color32::from_gray(150)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(picker) = state.image_picker.as_mut() {
|
if let Some(picker) = state.file_picker.as_mut() {
|
||||||
if go_up {
|
if go_up {
|
||||||
picker.go_up();
|
picker.go_up();
|
||||||
}
|
}
|
||||||
@@ -1074,12 +1113,15 @@ fn image_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut V
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(path) = chosen {
|
if let Some(path) = chosen {
|
||||||
state.last_image_dir = state.image_picker.as_ref().map(|p| p.dir.clone());
|
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||||
actions.push(UiAction::InsertImageFile(path));
|
actions.push(match picker_kind {
|
||||||
|
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||||
|
PickerKind::Pdf => UiAction::ImportPdf(path),
|
||||||
|
});
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if close {
|
if close {
|
||||||
state.image_picker = None;
|
state.file_picker = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1218,7 +1260,7 @@ pub fn render_ui(
|
|||||||
autosave_status: AutosaveStatus,
|
autosave_status: AutosaveStatus,
|
||||||
updater: &crate::updater::UpdaterController,
|
updater: &crate::updater::UpdaterController,
|
||||||
) -> Vec<UiAction> {
|
) -> Vec<UiAction> {
|
||||||
let mut actions = Vec::new();
|
let mut actions = std::mem::take(&mut state.queued_actions);
|
||||||
|
|
||||||
let mut pen_btn_rect = None;
|
let mut pen_btn_rect = None;
|
||||||
let mut high_btn_rect = None;
|
let mut high_btn_rect = None;
|
||||||
@@ -1975,6 +2017,13 @@ pub fn render_ui(
|
|||||||
{
|
{
|
||||||
state.open_image_picker();
|
state.open_image_picker();
|
||||||
}
|
}
|
||||||
|
if ui
|
||||||
|
.add_sized([ui.available_width(), 30.0], egui::Button::new("📄 PDF importieren …"))
|
||||||
|
.on_hover_text("Vorlesungsfolien, Übungsblätter oder Formulare als neues Notizbuch öffnen und darauf schreiben")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
state.open_pdf_picker();
|
||||||
|
}
|
||||||
|
|
||||||
section(ui, "HINTERGRUND DIESER SEITE");
|
section(ui, "HINTERGRUND DIESER SEITE");
|
||||||
let current_bg = notebook.current_page().background;
|
let current_bg = notebook.current_page().background;
|
||||||
@@ -2436,8 +2485,8 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3c. Dateiauswahl „Bild einfügen“
|
// 3c. Dateiauswahl „Bild einfügen“
|
||||||
if state.image_picker.is_some() {
|
if state.file_picker.is_some() {
|
||||||
image_picker_dialog(ctx, state, &mut actions);
|
file_picker_dialog(ctx, state, &mut actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3c. Release- & Update-Center (Gitea Releases)
|
// 3c. Release- & Update-Center (Gitea Releases)
|
||||||
|
|||||||
Reference in New Issue
Block a user