diff --git a/Cargo.lock b/Cargo.lock index ce9c3b9..a5e51d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ "ab_glyph_rasterizer", - "owned_ttf_parser 0.25.1", + "owned_ttf_parser", ] [[package]] @@ -24,6 +24,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "ahash" version = "0.8.12" @@ -219,6 +230,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-sys" version = "0.2.1" @@ -259,17 +288,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "bstr" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" -dependencies = [ - "memchr", - "regex-automata", - "serde_core", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -334,6 +352,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.6.0" @@ -370,6 +397,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -454,6 +502,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -500,6 +554,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -509,6 +569,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -540,6 +609,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -563,20 +641,25 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -630,6 +713,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher", +] + [[package]] name = "ecolor" version = "0.27.2" @@ -966,6 +1058,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -975,7 +1068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -1276,6 +1369,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "icrate" version = "0.0.4" @@ -1455,17 +1557,17 @@ dependencies = [ "hayro-svg", "image", "log", + "lopdf", "minisign-verify", "naga", "octotablet", "pollster", - "printpdf", "quick-xml", "resvg", "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "ureq", "url", @@ -1477,6 +1579,16 @@ dependencies = [ "winit", ] +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1621,12 +1733,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1668,19 +1774,29 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lopdf" -version = "0.31.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +checksum = "d43f64cb8c0d673ff84245a6de9e0172c0cc05dc5dd9d263660b5b4a02b6c4ae" dependencies = [ + "aes", + "bitflags 2.13.2", + "brotli-decompressor", + "cbc", + "ecb", "encoding_rs", "flate2", + "getrandom 0.4.3", + "indexmap", "itoa", - "linked-hash-map", "log", - "md5", - "pom", - "time", - "weezl", + "md-5", + "nom", + "rand", + "rangemap", + "sha2 0.11.1", + "stringprep", + "thiserror 2.0.21", + "weezl 0.2.1", ] [[package]] @@ -1693,10 +1809,14 @@ dependencies = [ ] [[package]] -name = "md5" -version = "0.7.0" +name = "md-5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] [[package]] name = "memchr" @@ -1827,10 +1947,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] -name = "num-conv" -version = "0.2.2" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] [[package]] name = "num-traits" @@ -2012,22 +2135,13 @@ dependencies = [ "libredox", ] -[[package]] -name = "owned_ttf_parser" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" -dependencies = [ - "ttf-parser 0.19.2", -] - [[package]] name = "owned_ttf_parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "ttf-parser 0.25.1", + "ttf-parser", ] [[package]] @@ -2196,15 +2310,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "pom" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" -dependencies = [ - "bstr", -] - [[package]] name = "potential_utf" version = "0.1.6" @@ -2214,30 +2319,12 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" - [[package]] name = "presser" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" -[[package]] -name = "printpdf" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" -dependencies = [ - "js-sys", - "lopdf", - "owned_ttf_parser 0.19.0", - "time", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2304,12 +2391,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "range-alloc" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -2371,12 +2481,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" - [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -2602,8 +2706,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -2744,6 +2859,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strum" version = "0.26.3" @@ -2898,40 +3024,10 @@ dependencies = [ "flate2", "half", "quick-error", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-skia" version = "0.11.4" @@ -2993,6 +3089,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" @@ -3032,12 +3134,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "ttf-parser" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" - [[package]] name = "ttf-parser" version = "0.25.1" @@ -3059,12 +3155,33 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3430,6 +3547,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wgpu" version = "0.19.4" diff --git a/Cargo.toml b/Cargo.toml index b365e18..7777416 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,8 +24,8 @@ glam = { version = "0.27", features = ["serde"] } # Asynchroner Starter für wgpu-Device-Setup pollster = "0.3" -# PDF-Vektorexport -printpdf = "0.7" +# PDF: writing the export (own writer in src/pdf_writer.rs) and merging imported PDFs +lopdf = { version = "0.46", default-features = false } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" diff --git a/README.de.md b/README.de.md index 15ee8bb..a8b4963 100644 --- a/README.de.md +++ b/README.de.md @@ -179,6 +179,7 @@ src/ ├── assets.rs # Bilder und PDFs im Notizbuchordner ├── pdf_doc.rs # PDF lesen und rendern (hayro) ├── pdf_merge.rs # Importierte PDF-Seiten beim Export unterlegen +├── pdf_writer.rs # Kleiner PDF-Schreiber für den Export (lopdf) ├── import.rs # Import als neues Notizbuch (Gemeinsames) ├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern ├── rnote.rs # Import von Rnote-Notizbüchern @@ -189,7 +190,7 @@ src/ ├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients) ├── library.rs # Ordner der Notizbücher (library.json) ├── autosave.rs # Speichern im Hintergrund -├── export.rs # PDF-Export (printpdf) +├── export.rs # PDF-Export ├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild ├── file_picker.rs # Eingebaute Dateiauswahl ├── settings.rs # Einstellungen (~/.config/ink-notes/ui.json) diff --git a/README.md b/README.md index 5392dcf..fcd620b 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ src/ ├── assets.rs # Images and PDFs in the notebook folder ├── pdf_doc.rs # Reading and rendering PDFs (hayro) ├── pdf_merge.rs # Underlaying imported PDF pages on export +├── pdf_writer.rs # Small PDF writer for the export (lopdf) ├── import.rs # Import as a new notebook (shared parts) ├── xopp.rs # Import of Xournal++/Xournal notebooks ├── rnote.rs # Import of Rnote notebooks @@ -191,7 +192,7 @@ src/ ├── disk_watch.rs # Taking over changes from other devices (sync clients) ├── library.rs # Notebook folders (library.json) ├── autosave.rs # Saving in the background -├── export.rs # PDF export (printpdf) +├── export.rs # PDF export ├── page_export.rs # Page as PNG/SVG, selection as image ├── file_picker.rs # Built-in file picker ├── settings.rs # Settings (~/.config/ink-notes/ui.json) diff --git a/docs/ARCHITECTURE.de.md b/docs/ARCHITECTURE.de.md index 66f5850..b134c27 100644 --- a/docs/ARCHITECTURE.de.md +++ b/docs/ARCHITECTURE.de.md @@ -189,13 +189,13 @@ Jede Seite hat einen eigenen Verlauf (`history.rs`): Hinzufügen, Entfernen, Ers ## 8. Export -`export.rs` erzeugt das PDF mit `printpdf`. Jede Seite bekommt ihre eigene Größe und diese Ebenen: +`export.rs` erzeugt das PDF mit `pdf_writer.rs`, einem kleinen Schreiber auf Basis von `lopdf` (Ebenen als optionale Inhaltsgruppen, eingebettete Schriften mit ToUnicode-Tabelle, Bilder, Lesezeichen). Jede Seite bekommt ihre eigene Größe und diese Ebenen: 1. **Background:** Karo, Linien oder Punkte in der gewählten Musterfarbe (immer auf weißem Papier). 2. **PDF** (nur im Notfall): importierte PDF-Seite als Bild, falls sie sich nicht als Vektor übernehmen lässt. 3. **Images:** eingefügte Bilder (höchstens 300 dpi). 4. **Text:** Textfelder als echter Text mit eingebetteter Schrift (durchsuchbar, kopierbar). Jedes Feld liegt in einem eigenen Koordinatensystem, das eine Transformationsmatrix auf seine Ecken abbildet. -5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); printpdf kann solche Zustände nicht anlegen, deshalb trägt `add_alpha_states` sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt. +5. **Ink:** Striche als gefüllte Polygone, Formen als Pfade. Halbtransparente Striche (Textmarker) bekommen einen Grafikzustand mit Deckkraft (`ca`/`CA`); `add_alpha_states` trägt sie nach dem Speichern mit `lopdf` in die Seitenressourcen ein. Jeder Strich ist ein einziger Pfad und deckt sich daher nicht selbst doppelt. Der Umriss eines Strichs wird vor dem Export vereinfacht (`geometry::simplify_closed_outline`, höchstens 0,01 mm vom gezeichneten entfernt): Das Netz hat etwa 16 Punkte je Stiftpunkt, und mit allen dauerte der Export einer vollen Seite Sekunden. `INK_NOTES_EXACT_OUTLINES=1` exportiert wie bisher jeden Punkt. Importierte PDF-Seiten werden anschließend als Form-XObject unter die Seite gelegt (`pdf_merge.rs`), bleiben also Vektorgrafik. Seitentitel werden zu Lesezeichen. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb1b2c8..8d2f14a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -189,13 +189,13 @@ Each page has its own history (`history.rs`): adding, removing, replacing and mo ## 8. Export -`export.rs` creates the PDF with `printpdf`. Each page gets its own size and these layers: +`export.rs` creates the PDF with `pdf_writer.rs`, a small writer on top of `lopdf` (layers as optional content groups, embedded fonts with a ToUnicode map, images, bookmarks). Each page gets its own size and these layers: 1. **Background:** squares, lines or dots in the chosen pattern color (always on white paper). 2. **PDF** (fallback only): imported PDF page as an image, if it cannot be taken over as vectors. 3. **Images:** inserted images (at most 300 dpi). 4. **Text:** text boxes as real text with embedded font (searchable, copyable). Each box lives in its own coordinate system that a transformation matrix maps onto its corners. -5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); printpdf cannot create such states, so `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before. +5. **Ink:** strokes as filled polygons, shapes as paths. Semi-transparent strokes (highlighter) get a graphics state with opacity (`ca`/`CA`); `add_alpha_states` adds them to the page resources with `lopdf` after saving. Each stroke is a single path and therefore does not cover itself twice. The outline of a stroke is simplified before export (`geometry::simplify_closed_outline`, at most 0.01 mm away from the drawn one): the mesh has about 16 points per pen sample, and with all of them a full page took seconds to export. `INK_NOTES_EXACT_OUTLINES=1` exports every point as before. Imported PDF pages are then placed under the page as a form XObject (`pdf_merge.rs`), so they stay vector graphics. Page titles become bookmarks. diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index 59175a7..3c5d7e1 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -38,6 +38,19 @@ "dest": "cargo/vendor/adler2-2.0.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/aes/aes-0.9.3.crate", + "sha256": "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32", + "dest": "cargo/vendor/aes-0.9.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32\", \"files\": {}}", + "dest": "cargo/vendor/aes-0.9.3", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -337,6 +350,32 @@ "dest": "cargo/vendor/block-buffer-0.10.4", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-buffer/block-buffer-0.12.1.crate", + "sha256": "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa", + "dest": "cargo/vendor/block-buffer-0.12.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa\", \"files\": {}}", + "dest": "cargo/vendor/block-buffer-0.12.1", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/block-padding/block-padding-0.4.2.crate", + "sha256": "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b", + "dest": "cargo/vendor/block-padding-0.4.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b\", \"files\": {}}", + "dest": "cargo/vendor/block-padding-0.4.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -493,6 +532,19 @@ "dest": "cargo/vendor/calloop-wayland-source-0.2.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cbc/cbc-0.2.1.crate", + "sha256": "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896", + "dest": "cargo/vendor/cbc-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896\", \"files\": {}}", + "dest": "cargo/vendor/cbc-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -558,6 +610,32 @@ "dest": "cargo/vendor/cfg_aliases-0.2.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/chacha20/chacha20-0.10.2.crate", + "sha256": "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06", + "dest": "cargo/vendor/chacha20-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06\", \"files\": {}}", + "dest": "cargo/vendor/chacha20-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cipher/cipher-0.5.2.crate", + "sha256": "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c", + "dest": "cargo/vendor/cipher-0.5.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c\", \"files\": {}}", + "dest": "cargo/vendor/cipher-0.5.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -675,6 +753,19 @@ "dest": "cargo/vendor/concurrent-queue-2.5.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/const-oid/const-oid-0.10.2.crate", + "sha256": "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c", + "dest": "cargo/vendor/const-oid-0.10.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c\", \"files\": {}}", + "dest": "cargo/vendor/const-oid-0.10.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -740,6 +831,19 @@ "dest": "cargo/vendor/core_detect-1.0.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cpubits/cpubits-0.1.1.crate", + "sha256": "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae", + "dest": "cargo/vendor/cpubits-0.1.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae\", \"files\": {}}", + "dest": "cargo/vendor/cpubits-0.1.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -753,6 +857,19 @@ "dest": "cargo/vendor/cpufeatures-0.2.17", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/cpufeatures/cpufeatures-0.3.1.crate", + "sha256": "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566", + "dest": "cargo/vendor/cpufeatures-0.3.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566\", \"files\": {}}", + "dest": "cargo/vendor/cpufeatures-0.3.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -805,6 +922,19 @@ "dest": "cargo/vendor/crypto-common-0.1.7", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/crypto-common/crypto-common-0.2.2.crate", + "sha256": "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453", + "dest": "cargo/vendor/crypto-common-0.2.2" + }, + { + "type": "inline", + "contents": "{\"package\": \"ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453\", \"files\": {}}", + "dest": "cargo/vendor/crypto-common-0.2.2", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -870,6 +1000,19 @@ "dest": "cargo/vendor/digest-0.10.7", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/digest/digest-0.11.3.crate", + "sha256": "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2", + "dest": "cargo/vendor/digest-0.11.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2\", \"files\": {}}", + "dest": "cargo/vendor/digest-0.11.3", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -948,6 +1091,19 @@ "dest": "cargo/vendor/downcast-rs-1.2.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/ecb/ecb-0.2.1.crate", + "sha256": "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac", + "dest": "cargo/vendor/ecb-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac\", \"files\": {}}", + "dest": "cargo/vendor/ecb-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1793,6 +1949,19 @@ "dest": "cargo/vendor/home-0.5.12", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/hybrid-array/hybrid-array-0.4.15.crate", + "sha256": "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17", + "dest": "cargo/vendor/hybrid-array-0.4.15" + }, + { + "type": "inline", + "contents": "{\"package\": \"27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17\", \"files\": {}}", + "dest": "cargo/vendor/hybrid-array-0.4.15", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -1975,6 +2144,19 @@ "dest": "cargo/vendor/indexmap-2.14.2", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/inout/inout-0.2.5.crate", + "sha256": "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f", + "dest": "cargo/vendor/inout-0.2.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f\", \"files\": {}}", + "dest": "cargo/vendor/inout-0.2.5", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2261,6 +2443,19 @@ "dest": "cargo/vendor/log-0.4.34", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/lopdf/lopdf-0.46.0.crate", + "sha256": "d43f64cb8c0d673ff84245a6de9e0172c0cc05dc5dd9d263660b5b4a02b6c4ae", + "dest": "cargo/vendor/lopdf-0.46.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"d43f64cb8c0d673ff84245a6de9e0172c0cc05dc5dd9d263660b5b4a02b6c4ae\", \"files\": {}}", + "dest": "cargo/vendor/lopdf-0.46.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2287,6 +2482,19 @@ "dest": "cargo/vendor/malloc_buf-0.0.6", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/md-5/md-5-0.11.0.crate", + "sha256": "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98", + "dest": "cargo/vendor/md-5-0.11.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98\", \"files\": {}}", + "dest": "cargo/vendor/md-5-0.11.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -2469,6 +2677,19 @@ "dest": "cargo/vendor/nohash-hasher-0.2.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/nom/nom-8.0.0.crate", + "sha256": "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405", + "dest": "cargo/vendor/nom-8.0.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405\", \"files\": {}}", + "dest": "cargo/vendor/nom-8.0.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3158,6 +3379,32 @@ "dest": "cargo/vendor/r-efi-6.0.0", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand/rand-0.10.3.crate", + "sha256": "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af", + "dest": "cargo/vendor/rand-0.10.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af\", \"files\": {}}", + "dest": "cargo/vendor/rand-0.10.3", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rand_core/rand_core-0.10.1.crate", + "sha256": "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69", + "dest": "cargo/vendor/rand_core-0.10.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69\", \"files\": {}}", + "dest": "cargo/vendor/rand_core-0.10.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3171,6 +3418,19 @@ "dest": "cargo/vendor/range-alloc-0.1.5", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/rangemap/rangemap-1.8.0.crate", + "sha256": "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d", + "dest": "cargo/vendor/rangemap-1.8.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d\", \"files\": {}}", + "dest": "cargo/vendor/rangemap-1.8.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3574,6 +3834,19 @@ "dest": "cargo/vendor/sha2-0.10.9", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/sha2/sha2-0.11.1.crate", + "sha256": "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19", + "dest": "cargo/vendor/sha2-0.11.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19\", \"files\": {}}", + "dest": "cargo/vendor/sha2-0.11.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -3782,6 +4055,19 @@ "dest": "cargo/vendor/strict-num-0.1.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/stringprep/stringprep-0.1.5.crate", + "sha256": "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1", + "dest": "cargo/vendor/stringprep-0.1.5" + }, + { + "type": "inline", + "contents": "{\"package\": \"7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1\", \"files\": {}}", + "dest": "cargo/vendor/stringprep-0.1.5", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -4081,6 +4367,19 @@ "dest": "cargo/vendor/tinystr-0.8.4", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/tinyvec/tinyvec-1.13.3.crate", + "sha256": "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee", + "dest": "cargo/vendor/tinyvec-1.13.3" + }, + { + "type": "inline", + "contents": "{\"package\": \"fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee\", \"files\": {}}", + "dest": "cargo/vendor/tinyvec-1.13.3", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -4185,6 +4484,19 @@ "dest": "cargo/vendor/typenum-1.20.1", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-bidi/unicode-bidi-0.3.18.crate", + "sha256": "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5", + "dest": "cargo/vendor/unicode-bidi-0.3.18" + }, + { + "type": "inline", + "contents": "{\"package\": \"5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5\", \"files\": {}}", + "dest": "cargo/vendor/unicode-bidi-0.3.18", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -4198,6 +4510,32 @@ "dest": "cargo/vendor/unicode-ident-1.0.26", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-normalization/unicode-normalization-0.1.26.crate", + "sha256": "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d", + "dest": "cargo/vendor/unicode-normalization-0.1.26" + }, + { + "type": "inline", + "contents": "{\"package\": \"75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d\", \"files\": {}}", + "dest": "cargo/vendor/unicode-normalization-0.1.26", + "dest-filename": ".cargo-checksum.json" + }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/unicode-properties/unicode-properties-0.1.4.crate", + "sha256": "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d", + "dest": "cargo/vendor/unicode-properties-0.1.4" + }, + { + "type": "inline", + "contents": "{\"package\": \"7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d\", \"files\": {}}", + "dest": "cargo/vendor/unicode-properties-0.1.4", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", @@ -4640,6 +4978,19 @@ "dest": "cargo/vendor/weezl-0.1.12", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/weezl/weezl-0.2.1.crate", + "sha256": "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2", + "dest": "cargo/vendor/weezl-0.2.1" + }, + { + "type": "inline", + "contents": "{\"package\": \"d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2\", \"files\": {}}", + "dest": "cargo/vendor/weezl-0.2.1", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", diff --git a/src/export.rs b/src/export.rs index bc5f128..d865899 100644 --- a/src/export.rs +++ b/src/export.rs @@ -1,4 +1,4 @@ -//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs via printpdf. +//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs (PDF-Ausgabe: [`crate::pdf_writer`]). //! //! Features: //! - Hochformat DIN A4 (210 mm x 297 mm) @@ -8,7 +8,7 @@ //! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko) //! - Zwei Export-Strategien: //! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke) -//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line` +//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `pdf_writer::Line` use std::collections::HashMap; use std::error::Error; @@ -16,14 +16,15 @@ use std::fs::File; use std::io::BufWriter; use std::path::{Path, PathBuf}; -use printpdf::path::{PaintMode, WindingOrder}; -use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; - use crate::assets::AssetStore; use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING}; use crate::document::{ Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, }; +use crate::pdf_writer::{ + Color, Line, Mm, PaintMode, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb, + WindingOrder, +}; use crate::t; use ab_glyph::Font as _; @@ -147,19 +148,19 @@ fn pdf_page_mm(page: &Page) -> (Mm, Mm) { /// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe /// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben. -fn begin_page_layer(layer: &printpdf::PdfLayerReference, page: &Page) { +fn begin_page_layer(layer: &crate::pdf_writer::PdfLayerReference, page: &Page) { layer.save_graphics_state(); let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT; if dy != 0.0 { - layer.set_ctm(printpdf::CurTransMat::Translate( - printpdf::Pt(0.0), - printpdf::Pt(dy), + layer.set_ctm(crate::pdf_writer::CurTransMat::Translate( + crate::pdf_writer::Pt(0.0), + crate::pdf_writer::Pt(dy), )); } } /// Beendet eine mit [`begin_page_layer`] begonnene Ebene. -fn end_page_layer(layer: &printpdf::PdfLayerReference) { +fn end_page_layer(layer: &crate::pdf_writer::PdfLayerReference) { layer.restore_graphics_state(); } @@ -211,7 +212,7 @@ pub fn export_notebook_to_bytes( return export_notebook_to_bytes(&cropped, strategy, assets, pattern); } let doc = generate_pdf_document(notebook, strategy, assets, pattern)?; - let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?; + let bytes = add_alpha_states(doc.save_to_bytes()?, notebook)?; // Importierte PDF-Seiten als Vektorgrafik unterlegen let underlays = pdf_underlays(notebook); @@ -237,7 +238,7 @@ pub fn export_notebook_to_bytes( raster.insert(underlay.target_page); } let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?; - let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?; + let bytes = add_alpha_states(doc.save_to_bytes()?, notebook)?; let remaining: Vec<_> = underlays .into_iter() .filter(|u| !raster.contains(&u.target_page)) @@ -249,35 +250,6 @@ pub fn export_notebook_to_bytes( Ok(merged) } -/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber -/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel -/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben. -fn fix_text_encoding(bytes: Vec, notebook: &Notebook) -> Result, Box> { - let needs_fix = notebook - .pages - .iter() - .filter_map(|p| p.title.as_deref()) - .any(|t| !t.is_ascii()); - if !needs_fix { - return Ok(bytes); - } - let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?; - for object in doc.objects.values_mut() { - let Ok(dict) = object.as_dict_mut() else { - continue; - }; - if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") { - if let Some(utf16) = utf16be_text(raw) { - *raw = utf16; - *format = printpdf::lopdf::StringFormat::Hexadecimal; - } - } - } - let mut out = Vec::new(); - doc.save_to(&mut out)?; - Ok(out) -} - /// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent /// gerundet); `None` für deckende Farben. fn alpha_state_name(alpha: f32) -> Option { @@ -285,12 +257,12 @@ fn alpha_state_name(alpha: f32) -> Option { (percent < 100).then(|| format!("InkAlpha{percent}")) } -/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit +/// Grafikzustände mit Deckkraft: Die Tintenebene verweist mit /// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier /// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF /// unverändert. fn add_alpha_states(bytes: Vec, notebook: &Notebook) -> Result, Box> { - use printpdf::lopdf::{Dictionary, Object}; + use lopdf::{Dictionary, Object}; let mut states: Vec<(String, f32)> = notebook .pages @@ -305,7 +277,7 @@ fn add_alpha_states(bytes: Vec, notebook: &Notebook) -> Result, Box< states.sort_by(|a, b| a.0.cmp(&b.0)); states.dedup_by(|a, b| a.0 == b.0); - let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?; + let mut doc = lopdf::Document::load_mem(&bytes)?; let page_ids: Vec<_> = doc.get_pages().into_values().collect(); for page_id in page_ids { let resources = dict_object_id(&mut doc, page_id, b"Resources")?; @@ -328,11 +300,11 @@ fn add_alpha_states(bytes: Vec, notebook: &Notebook) -> Result, Box< /// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes /// Wörterbuch wird dafür zu einem eigenen Objekt. fn dict_object_id( - doc: &mut printpdf::lopdf::Document, - owner: printpdf::lopdf::ObjectId, + doc: &mut lopdf::Document, + owner: lopdf::ObjectId, key: &[u8], -) -> Result> { - use printpdf::lopdf::{Dictionary, Object}; +) -> Result> { + use lopdf::{Dictionary, Object}; let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned(); if let Some(Object::Reference(id)) = entry { @@ -349,20 +321,6 @@ fn dict_object_id( Ok(id) } -/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder -/// ungültigem UTF-8. -fn utf16be_text(raw: &[u8]) -> Option> { - let text = std::str::from_utf8(raw).ok()?; - if text.is_ascii() { - return None; - } - let mut out = vec![0xFE, 0xFF]; - for unit in text.encode_utf16() { - out.extend_from_slice(&unit.to_be_bytes()); - } - Some(out) -} - /// Unterlagen für alle Seiten mit importierter PDF-Seite. fn pdf_underlays(notebook: &Notebook) -> Vec { notebook @@ -387,7 +345,7 @@ fn pdf_underlays(notebook: &Notebook) -> Vec { .collect() } -/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs. +/// Generiert das `crate::pdf_writer::PdfDocumentReference` für alle Seiten des Notizbuchs. /// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt /// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter. pub fn generate_pdf_document( @@ -447,7 +405,7 @@ fn generate_pdf_document_with( } let page_ref = doc.get_page(page_idx); render_background_layer(&page_ref.get_layer(layer_bg), page, pattern); - let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| { + let layer = |name: &str, render: &dyn Fn(&crate::pdf_writer::PdfLayerReference)| { let layer = page_ref.add_layer(name); begin_page_layer(&layer, page); render(&layer); @@ -470,7 +428,7 @@ fn generate_pdf_document_with( /// Rendert das Vektorraster auf Layer 1 ("Background"). fn render_background_layer( - layer: &printpdf::PdfLayerReference, + layer: &crate::pdf_writer::PdfLayerReference, page: &Page, pattern: PatternColor, ) { @@ -481,7 +439,7 @@ fn render_background_layer( .color(crate::background::PaperTheme::White) .map(crate::background::linear_to_srgb); let grid_color = Color::Rgb(Rgb::new(r, g, b, None)); - layer.set_outline_color(grid_color.clone()); + layer.set_outline_color(grid_color); layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt) layer.set_fill_color(grid_color); @@ -567,7 +525,11 @@ fn render_background_layer( } /// Rendert alle Striche einer Seite auf Layer 2 ("Ink"). -fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) { +fn render_ink_layer( + layer: &crate::pdf_writer::PdfLayerReference, + page: &Page, + strategy: ExportStrategy, +) { for stroke in &page.strokes { if stroke.is_object() { continue; @@ -578,9 +540,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: let alpha_state = alpha_state_name(stroke.color[3]); if let Some(name) = &alpha_state { layer.save_graphics_state(); - layer.add_operation(printpdf::lopdf::content::Operation::new( + layer.add_operation(lopdf::content::Operation::new( "gs", - vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())], + vec![lopdf::Object::Name(name.as_bytes().to_vec())], )); } if let Some(ref shape) = stroke.shape { @@ -648,8 +610,8 @@ fn pdf_image_xobject( assets: &AssetStore, asset: &str, corners: &[glam::Vec2; 4], -) -> Result> { - use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px}; +) -> Result> { + use crate::pdf_writer::{ColorSpace, ImageFilter, ImageXObject, Px}; let bytes = assets.read_bytes(asset)?; let decoded = image::load_from_memory(&bytes)?; @@ -665,12 +627,9 @@ fn pdf_image_xobject( width: Px(decoded.width() as usize), height: Px(decoded.height() as usize), color_space, - bits_per_component: ColorBits::Bit8, interpolate: true, image_data: bytes, image_filter: Some(ImageFilter::DCT), - smask: None, - clipping_bbox: None, }); } } @@ -700,7 +659,7 @@ fn pdf_image_xobject( /// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer /// Stelle eine graue Fläche, damit der Export nicht abbricht. fn render_image_layer( - layer: &printpdf::PdfLayerReference, + layer: &crate::pdf_writer::PdfLayerReference, page: &Page, assets: Option<&AssetStore>, ) { @@ -725,7 +684,7 @@ fn render_image_layer( fn embed_text_fonts( doc: &PdfDocumentReference, notebook: &Notebook, -) -> Result, Box> { +) -> Result, Box> { let mut fonts = HashMap::new(); let used = notebook .pages @@ -744,9 +703,9 @@ fn embed_text_fonts( /// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine /// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung. fn render_text_layer( - layer: &printpdf::PdfLayerReference, + layer: &crate::pdf_writer::PdfLayerReference, page: &Page, - fonts: &HashMap, + fonts: &HashMap, ) { for stroke in &page.strokes { let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else { @@ -763,7 +722,7 @@ fn render_text_layer( let o = page_to_pdf_pt(origin); let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y]; layer.save_graphics_state(); - layer.set_ctm(printpdf::CurTransMat::Raw(matrix)); + layer.set_ctm(crate::pdf_writer::CurTransMat::Raw(matrix)); layer.set_fill_color(Color::Rgb(Rgb::new( stroke.color[0], stroke.color[1], @@ -776,9 +735,9 @@ fn render_text_layer( if line.text.trim().is_empty() { continue; } - layer.set_text_matrix(printpdf::TextMatrix::Translate( - printpdf::Pt(0.0), - printpdf::Pt(-layout.baseline(i)), + layer.set_text_matrix(crate::pdf_writer::TextMatrix::Translate( + crate::pdf_writer::Pt(0.0), + crate::pdf_writer::Pt(-layout.baseline(i)), )); // Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em) let face = text.font.face(); @@ -796,45 +755,31 @@ fn render_text_layer( } /// Graue 1×1-Fläche für fehlende Bilder. -fn placeholder_xobject() -> printpdf::ImageXObject { +fn placeholder_xobject() -> crate::pdf_writer::ImageXObject { rgb_xobject(1, 1, vec![200, 200, 200]) } -fn rgb_xobject(width: u32, height: u32, rgb: Vec) -> printpdf::ImageXObject { - printpdf::ImageXObject { - width: printpdf::Px(width as usize), - height: printpdf::Px(height as usize), - color_space: printpdf::ColorSpace::Rgb, - bits_per_component: printpdf::ColorBits::Bit8, +fn rgb_xobject(width: u32, height: u32, rgb: Vec) -> crate::pdf_writer::ImageXObject { + crate::pdf_writer::ImageXObject { + width: crate::pdf_writer::Px(width as usize), + height: crate::pdf_writer::Px(height as usize), + color_space: crate::pdf_writer::ColorSpace::Rgb, interpolate: true, image_data: rgb, image_filter: None, - smask: 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, + layer: &crate::pdf_writer::PdfLayerReference, + xobject: crate::pdf_writer::ImageXObject, corners: &[glam::Vec2; 4], ) { - let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32); layer.save_graphics_state(); - layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners))); - // Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das - // Einheitsquadrat ab, die Matrix oben auf die Bildecken - printpdf::Image::from(xobject).add_to_layer( - layer.clone(), - printpdf::ImageTransform { - dpi: Some(72.0), - scale_x: Some(1.0 / width), - scale_y: Some(1.0 / height), - ..Default::default() - }, - ); + layer.set_ctm(crate::pdf_writer::CurTransMat::Raw(image_matrix(corners))); + layer.add_image(xobject); layer.restore_graphics_state(); } @@ -844,7 +789,7 @@ 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, + layer: &crate::pdf_writer::PdfLayerReference, page: &Page, assets: Option<&AssetStore>, ) { @@ -882,19 +827,19 @@ fn render_pdf_raster( /// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem) /// als verlustfreien Vektorpfad im PDF. fn render_recognized_shape( - layer: &printpdf::PdfLayerReference, + layer: &crate::pdf_writer::PdfLayerReference, shape: &crate::geometry::RecognizedShape, color: [f32; 4], ) { use crate::geometry::RecognizedShape; let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None)); - layer.set_outline_color(stroke_color.clone()); + layer.set_outline_color(stroke_color); layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke // Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch // runde Verbindungen von einem vorherigen Freihandstrich enthalten) - layer.set_line_cap_style(printpdf::LineCapStyle::Butt); - layer.set_line_join_style(printpdf::LineJoinStyle::Miter); + layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Butt); + layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Miter); match shape { RecognizedShape::Line { start, end } => { @@ -1178,7 +1123,7 @@ fn render_recognized_shape( } /// Strategie A: Gefülltes Polygon aus den Triangle-Strip-Hüllpunkten. -fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { +fn render_stroke_polygon(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) { if stroke.vertices.is_empty() { return; } @@ -1266,8 +1211,8 @@ fn exact_outlines() -> bool { *EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0")) } -/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line. -fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { +/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via crate::pdf_writer::Line. +fn render_stroke_bezier(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) { if stroke.points.is_empty() { return; } @@ -1279,8 +1224,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) { None, )); layer.set_outline_color(outline_color); - layer.set_line_cap_style(printpdf::LineCapStyle::Round); - layer.set_line_join_style(printpdf::LineJoinStyle::Round); + layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Round); + layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Round); // Durchschnittliche Linienstärke aus dem Druck berechnen let avg_pressure = @@ -1753,10 +1698,10 @@ mod tests { PatternColor::default(), ) .unwrap(); - let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); + let doc = lopdf::Document::load_mem(&bytes).unwrap(); assert_eq!(doc.get_pages().len(), 2); for page_id in doc.get_pages().into_values() { - let content = doc.get_page_content(page_id).unwrap(); + let content = doc.get_page_content(page_id); assert!( String::from_utf8_lossy(&content).starts_with("q "), "Originalseite fehlt als Unterlage" @@ -1864,7 +1809,7 @@ mod tests { PatternColor::default(), ) .unwrap(); - let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap(); + let doc = lopdf::Document::load_mem(&bytes).unwrap(); let catalog = doc.catalog().unwrap(); assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen"); @@ -1875,17 +1820,15 @@ mod tests { .filter_map(|o| o.as_dict().ok()) .filter(|d| d.has(b"Parent")) .filter_map(|d| match d.get(b"Title") { - Ok(printpdf::lopdf::Object::String(raw, _)) => { - Some(if raw.starts_with(&[0xFE, 0xFF]) { - let units: Vec = raw[2..] - .chunks(2) - .map(|c| u16::from_be_bytes([c[0], c[1]])) - .collect(); - String::from_utf16(&units).unwrap() - } else { - String::from_utf8(raw.clone()).unwrap() - }) - } + Ok(lopdf::Object::String(raw, _)) => Some(if raw.starts_with(&[0xFE, 0xFF]) { + let units: Vec = raw[2..] + .chunks(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + String::from_utf16(&units).unwrap() + } else { + String::from_utf8(raw.clone()).unwrap() + }), _ => None, }) .collect(); diff --git a/src/lib.rs b/src/lib.rs index dd0757f..e52ff33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod page_export; pub mod paths; pub mod pdf_doc; pub mod pdf_merge; +pub mod pdf_writer; pub mod platform; pub mod rnote; pub mod ruler; diff --git a/src/pdf_merge.rs b/src/pdf_merge.rs index 1efb511..2f2845a 100644 --- a/src/pdf_merge.rs +++ b/src/pdf_merge.rs @@ -1,13 +1,13 @@ //! 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 +//! Vorgehen: Das vom Export erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite //! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am //! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte. use std::collections::{BTreeMap, HashMap}; -use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream}; +use lopdf::{Dictionary, Document, Object, ObjectId, Stream}; use crate::t; @@ -361,7 +361,7 @@ mod tests { 2, "Quellseiten dürfen nicht als eigene Seiten auftauchen" ); - let first = doc.get_page_content(pages[0]).unwrap(); + let first = doc.get_page_content(pages[0]); let text = String::from_utf8_lossy(&first); assert!( text.starts_with("q "), diff --git a/src/pdf_writer.rs b/src/pdf_writer.rs new file mode 100644 index 0000000..8b7cbfa --- /dev/null +++ b/src/pdf_writer.rs @@ -0,0 +1,879 @@ +//! Small PDF writer on top of `lopdf` for the vector export. +//! +//! Replaces printpdf 0.7, which still depended on lopdf 0.31. The interface follows the part +//! of printpdf that [`crate::export`] used: a document with pages, each page with named +//! layers (optional content groups, shown as layers in PDF viewers) that collect drawing +//! operations, embedded TrueType fonts for real, searchable text, images and bookmarks. + +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::rc::Rc; + +use ab_glyph::Font as _; +use lopdf::content::{Content, Operation}; +use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat}; + +/// Length in millimetres. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Mm(pub f32); + +/// Length in PDF points (1/72 inch). +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Pt(pub f32); + +impl From for Pt { + fn from(mm: Mm) -> Self { + Pt(mm.0 * 72.0 / 25.4) + } +} + +/// Size in pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Px(pub usize); + +/// Point on the page (origin bottom left). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Point { + pub x: Pt, + pub y: Pt, +} + +impl Point { + pub fn new(x: Mm, y: Mm) -> Self { + Self { + x: x.into(), + y: y.into(), + } + } +} + +/// Open or closed polyline; a point marked `true` followed by another marked `true` starts a +/// cubic Bézier segment (the two are its control points, the next one its end point). +#[derive(Debug, Clone)] +pub struct Line { + pub points: Vec<(Point, bool)>, + pub is_closed: bool, +} + +/// How a polygon is painted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaintMode { + Fill, + Stroke, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindingOrder { + NonZero, +} + +/// Closed shape of one or more rings (same point marking as [`Line`]). +#[derive(Debug, Clone)] +pub struct Polygon { + pub rings: Vec>, + pub mode: PaintMode, + pub winding_order: WindingOrder, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rgb { + pub r: f32, + pub g: f32, + pub b: f32, +} + +impl Rgb { + /// The last argument (ICC profile in printpdf) is ignored. + pub fn new(r: f32, g: f32, b: f32, _icc: Option<()>) -> Self { + Self { r, g, b } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Color { + Rgb(Rgb), +} + +/// Change of the coordinate system (`cm`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CurTransMat { + Translate(Pt, Pt), + Raw([f32; 6]), +} + +/// Text position within a text section (`Tm`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextMatrix { + Translate(Pt, Pt), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineCapStyle { + Butt, + Round, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineJoinStyle { + Miter, + Round, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorSpace { + Rgb, + Greyscale, +} + +/// Image data already compressed in this format (embedded unchanged). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageFilter { + /// JPEG + DCT, +} + +/// Image with 8 bits per component. +#[derive(Debug, Clone)] +pub struct ImageXObject { + pub width: Px, + pub height: Px, + pub color_space: ColorSpace, + pub interpolate: bool, + /// Raw samples, or the compressed data for `image_filter` + pub image_data: Vec, + pub image_filter: Option, +} + +/// Font embedded with [`PdfDocumentReference::add_external_font`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndirectFontRef { + index: usize, +} + +impl IndirectFontRef { + fn resource_name(&self) -> String { + format!("F{}", self.index) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdfPageIndex(pub usize); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdfLayerIndex(pub usize); + +struct LayerData { + name: String, + operations: Vec, + /// Font of the current text section (to record the glyphs it uses) + current_font: Option, +} + +struct PageData { + width: Pt, + height: Pt, + layers: Vec, + images: Vec, +} + +struct FontData { + face: ab_glyph::FontRef<'static>, + bytes: &'static [u8], + used_glyphs: BTreeSet, +} + +struct DocumentData { + title: String, + pages: Vec, + fonts: Vec, + bookmarks: Vec<(String, usize)>, +} + +/// Entry point like printpdf's `PdfDocument::new`. +pub struct PdfDocument; + +impl PdfDocument { + /// New document with a first page of the given size and its first layer. + #[allow(clippy::new_ret_no_self)] + pub fn new( + title: &str, + width: Mm, + height: Mm, + layer: &str, + ) -> (PdfDocumentReference, PdfPageIndex, PdfLayerIndex) { + let doc = PdfDocumentReference(Rc::new(RefCell::new(DocumentData { + title: title.to_string(), + pages: Vec::new(), + fonts: Vec::new(), + bookmarks: Vec::new(), + }))); + let (page, layer) = doc.add_page(width, height, layer); + (doc, page, layer) + } +} + +/// The document being built; cheap to clone, all clones share it. +#[derive(Clone)] +pub struct PdfDocumentReference(Rc>); + +impl PdfDocumentReference { + /// Appends a page with its first layer. + pub fn add_page(&self, width: Mm, height: Mm, layer: &str) -> (PdfPageIndex, PdfLayerIndex) { + let mut doc = self.0.borrow_mut(); + doc.pages.push(PageData { + width: width.into(), + height: height.into(), + layers: vec![LayerData { + name: layer.to_string(), + operations: Vec::new(), + current_font: None, + }], + images: Vec::new(), + }); + (PdfPageIndex(doc.pages.len() - 1), PdfLayerIndex(0)) + } + + /// Bookmark (outline entry) pointing at a page. + pub fn add_bookmark(&self, title: String, page: PdfPageIndex) { + self.0.borrow_mut().bookmarks.push((title, page.0)); + } + + pub fn get_page(&self, page: PdfPageIndex) -> PdfPageReference { + PdfPageReference { + doc: self.clone(), + page: page.0, + } + } + + /// Embeds a TrueType/OpenType font with TrueType outlines (Type0 font, Identity-H). + pub fn add_external_font( + &self, + bytes: &'static [u8], + ) -> Result> { + let face = ab_glyph::FontRef::try_from_slice(bytes)?; + let mut doc = self.0.borrow_mut(); + doc.fonts.push(FontData { + face, + bytes, + used_glyphs: BTreeSet::new(), + }); + Ok(IndirectFontRef { + index: doc.fonts.len() - 1, + }) + } + + /// Writes the document. + pub fn save_to_bytes(self) -> Result, Box> { + let data = self.0.borrow(); + let mut doc = Document::with_version("1.5"); + let pages_id = doc.new_object_id(); + + // Fonts: shared by all pages + let mut font_resources = Dictionary::new(); + for (index, font) in data.fonts.iter().enumerate() { + let id = write_font(&mut doc, font, index)?; + font_resources.set(format!("F{index}"), Object::Reference(id)); + } + + // One optional content group per layer name, in the order of appearance + let mut layer_names: Vec<&str> = Vec::new(); + for layer in data.pages.iter().flat_map(|p| &p.layers) { + if !layer_names.contains(&layer.name.as_str()) { + layer_names.push(&layer.name); + } + } + let ocgs: Vec = layer_names + .iter() + .map(|name| { + let mut ocg = Dictionary::new(); + ocg.set("Type", Object::Name(b"OCG".to_vec())); + ocg.set("Name", text_string(name)); + doc.add_object(ocg) + }) + .collect(); + + let mut page_ids = Vec::new(); + for page in &data.pages { + let mut operations = Vec::new(); + let mut properties = Dictionary::new(); + for layer in &page.layers { + let index = layer_names + .iter() + .position(|n| *n == layer.name) + .unwrap_or(0); + let tag = format!("MC{index}"); + properties.set(tag.clone(), Object::Reference(ocgs[index])); + operations.push(Operation::new( + "BDC", + vec![Object::Name(b"OC".to_vec()), Object::Name(tag.into_bytes())], + )); + operations.push(Operation::new("q", vec![])); + operations.extend(layer.operations.iter().cloned()); + operations.push(Operation::new("Q", vec![])); + operations.push(Operation::new("EMC", vec![])); + } + let mut contents = Stream::new(Dictionary::new(), Content { operations }.encode()?); + contents.compress()?; + let contents_id = doc.add_object(contents); + + let mut xobjects = Dictionary::new(); + for (i, image) in page.images.iter().enumerate() { + let id = doc.add_object(image_stream(image)?); + xobjects.set(format!("X{i}"), Object::Reference(id)); + } + let mut resources = Dictionary::new(); + resources.set("Font", Object::Dictionary(font_resources.clone())); + resources.set("XObject", Object::Dictionary(xobjects)); + resources.set("Properties", Object::Dictionary(properties)); + let resources_id = doc.add_object(resources); + + let mut dict = Dictionary::new(); + dict.set("Type", Object::Name(b"Page".to_vec())); + dict.set("Parent", Object::Reference(pages_id)); + dict.set( + "MediaBox", + Object::Array(vec![ + 0.into(), + 0.into(), + page.width.0.into(), + page.height.0.into(), + ]), + ); + dict.set("Contents", Object::Reference(contents_id)); + dict.set("Resources", Object::Reference(resources_id)); + page_ids.push(doc.add_object(dict)); + } + + let mut pages = Dictionary::new(); + pages.set("Type", Object::Name(b"Pages".to_vec())); + pages.set( + "Kids", + Object::Array(page_ids.iter().map(|id| Object::Reference(*id)).collect()), + ); + pages.set("Count", Object::Integer(page_ids.len() as i64)); + doc.objects.insert(pages_id, Object::Dictionary(pages)); + + let mut catalog = Dictionary::new(); + catalog.set("Type", Object::Name(b"Catalog".to_vec())); + catalog.set("Pages", Object::Reference(pages_id)); + let ocg_refs: Vec = ocgs.iter().map(|id| Object::Reference(*id)).collect(); + let mut default_config = Dictionary::new(); + default_config.set("Order", Object::Array(ocg_refs.clone())); + let mut oc_properties = Dictionary::new(); + oc_properties.set("OCGs", Object::Array(ocg_refs)); + oc_properties.set("D", Object::Dictionary(default_config)); + catalog.set("OCProperties", Object::Dictionary(oc_properties)); + if !data.bookmarks.is_empty() { + let outlines = write_outlines(&mut doc, &data.bookmarks, &page_ids); + catalog.set("Outlines", Object::Reference(outlines)); + catalog.set("PageMode", Object::Name(b"UseOutlines".to_vec())); + } + let catalog_id = doc.add_object(catalog); + + let mut info = Dictionary::new(); + info.set("Title", text_string(&data.title)); + info.set("Producer", text_string("ink-notes")); + let info_id = doc.add_object(info); + + doc.trailer.set("Root", Object::Reference(catalog_id)); + doc.trailer.set("Info", Object::Reference(info_id)); + let mut out = Vec::new(); + doc.save_to(&mut out)?; + Ok(out) + } +} + +/// PDF text string: literal for ASCII, otherwise UTF-16BE with byte order mark. +pub fn text_string(text: &str) -> Object { + if text.is_ascii() { + return Object::String(text.as_bytes().to_vec(), StringFormat::Literal); + } + let mut bytes = vec![0xFE, 0xFF]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + Object::String(bytes, StringFormat::Hexadecimal) +} + +/// Outline tree with one entry per bookmark; returns the outline root. +fn write_outlines( + doc: &mut Document, + bookmarks: &[(String, usize)], + pages: &[ObjectId], +) -> ObjectId { + let root_id = doc.new_object_id(); + let ids: Vec = bookmarks.iter().map(|_| doc.new_object_id()).collect(); + for (i, (title, page)) in bookmarks.iter().enumerate() { + let mut item = Dictionary::new(); + item.set("Title", text_string(title)); + item.set("Parent", Object::Reference(root_id)); + if i > 0 { + item.set("Prev", Object::Reference(ids[i - 1])); + } + if let Some(next) = ids.get(i + 1) { + item.set("Next", Object::Reference(*next)); + } + if let Some(page_id) = pages.get(*page) { + item.set( + "Dest", + Object::Array(vec![ + Object::Reference(*page_id), + Object::Name(b"Fit".to_vec()), + ]), + ); + } + doc.objects.insert(ids[i], Object::Dictionary(item)); + } + let mut root = Dictionary::new(); + root.set("Type", Object::Name(b"Outlines".to_vec())); + root.set("First", Object::Reference(ids[0])); + root.set("Last", Object::Reference(ids[ids.len() - 1])); + root.set("Count", Object::Integer(ids.len() as i64)); + doc.objects.insert(root_id, Object::Dictionary(root)); + root_id +} + +/// Image XObject stream. +fn image_stream(image: &ImageXObject) -> Result> { + let mut dict = Dictionary::new(); + dict.set("Type", Object::Name(b"XObject".to_vec())); + dict.set("Subtype", Object::Name(b"Image".to_vec())); + dict.set("Width", Object::Integer(image.width.0 as i64)); + dict.set("Height", Object::Integer(image.height.0 as i64)); + let color_space: &[u8] = match image.color_space { + ColorSpace::Rgb => b"DeviceRGB", + ColorSpace::Greyscale => b"DeviceGray", + }; + dict.set("ColorSpace", Object::Name(color_space.to_vec())); + dict.set("BitsPerComponent", Object::Integer(8)); + dict.set("Interpolate", Object::Boolean(image.interpolate)); + match image.image_filter { + Some(ImageFilter::DCT) => { + dict.set("Filter", Object::Name(b"DCTDecode".to_vec())); + Ok(Stream::new(dict, image.image_data.clone()).with_compression(false)) + } + None => { + let mut stream = Stream::new(dict, image.image_data.clone()); + stream.compress()?; + Ok(stream) + } + } +} + +/// Embeds a font as Type0 font with a CIDFontType2 descendant (glyph ids as character codes), +/// widths and a ToUnicode map of the glyphs used, so that the text can be searched and copied. +fn write_font( + doc: &mut Document, + font: &FontData, + index: usize, +) -> Result> { + let face = &font.face; + let units_per_em = face.units_per_em().unwrap_or(1000.0); + let scale = 1000.0 / units_per_em; + let name = format!("InkNotesFont{index}"); + + let mut file = Stream::new( + Dictionary::from_iter(vec![("Length1", Object::Integer(font.bytes.len() as i64))]), + font.bytes.to_vec(), + ); + file.compress()?; + let file_id = doc.add_object(file); + + let ascent = (face.ascent_unscaled() * scale).round() as i64; + let descent = (face.descent_unscaled() * scale).round() as i64; + let mut descriptor = Dictionary::new(); + descriptor.set("Type", Object::Name(b"FontDescriptor".to_vec())); + descriptor.set("FontName", Object::Name(name.clone().into_bytes())); + descriptor.set("Flags", Object::Integer(32)); + descriptor.set( + "FontBBox", + Object::Array(vec![0.into(), descent.into(), 1000.into(), ascent.into()]), + ); + descriptor.set("ItalicAngle", Object::Integer(0)); + descriptor.set("Ascent", Object::Integer(ascent)); + descriptor.set("Descent", Object::Integer(descent)); + descriptor.set("CapHeight", Object::Integer(ascent)); + descriptor.set("StemV", Object::Integer(80)); + descriptor.set("FontFile2", Object::Reference(file_id)); + let descriptor_id = doc.add_object(descriptor); + + // Widths of the glyphs used: gid [width] + let mut widths = Vec::new(); + for &gid in &font.used_glyphs { + let width = face.h_advance_unscaled(ab_glyph::GlyphId(gid)) * scale; + widths.push(Object::Integer(gid as i64)); + widths.push(Object::Array(vec![Object::Integer(width.round() as i64)])); + } + let mut system_info = Dictionary::new(); + system_info.set("Registry", Object::string_literal("Adobe")); + system_info.set("Ordering", Object::string_literal("Identity")); + system_info.set("Supplement", Object::Integer(0)); + let mut descendant = Dictionary::new(); + descendant.set("Type", Object::Name(b"Font".to_vec())); + descendant.set("Subtype", Object::Name(b"CIDFontType2".to_vec())); + descendant.set("BaseFont", Object::Name(name.clone().into_bytes())); + descendant.set("CIDSystemInfo", Object::Dictionary(system_info)); + descendant.set("FontDescriptor", Object::Reference(descriptor_id)); + descendant.set("CIDToGIDMap", Object::Name(b"Identity".to_vec())); + descendant.set("DW", Object::Integer(1000)); + descendant.set("W", Object::Array(widths)); + let descendant_id = doc.add_object(descendant); + + let mut to_unicode = Stream::new(Dictionary::new(), to_unicode_cmap(font).into_bytes()); + to_unicode.compress()?; + let to_unicode_id = doc.add_object(to_unicode); + + let mut dict = Dictionary::new(); + dict.set("Type", Object::Name(b"Font".to_vec())); + dict.set("Subtype", Object::Name(b"Type0".to_vec())); + dict.set("BaseFont", Object::Name(name.into_bytes())); + dict.set("Encoding", Object::Name(b"Identity-H".to_vec())); + dict.set( + "DescendantFonts", + Object::Array(vec![Object::Reference(descendant_id)]), + ); + dict.set("ToUnicode", Object::Reference(to_unicode_id)); + Ok(doc.add_object(dict)) +} + +/// CMap from the glyph ids used to their characters (UTF-16BE). +fn to_unicode_cmap(font: &FontData) -> String { + let mut chars: BTreeMap = BTreeMap::new(); + for (gid, c) in font.face.codepoint_ids() { + if font.used_glyphs.contains(&gid.0) { + // Prefer the lowest code point (e.g. a letter over a compatibility form) + let entry = chars.entry(gid.0).or_insert(c); + if c < *entry { + *entry = c; + } + } + } + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\ + /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\ + /CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\ + 1 begincodespacerange\n<0000> \nendcodespacerange\n", + ); + let entries: Vec<(u16, char)> = chars.into_iter().collect(); + for block in entries.chunks(100) { + cmap.push_str(&format!("{} beginbfchar\n", block.len())); + for (gid, c) in block { + let mut units = [0u16; 2]; + let hex: String = c + .encode_utf16(&mut units) + .iter() + .map(|u| format!("{u:04X}")) + .collect(); + cmap.push_str(&format!("<{gid:04X}> <{hex}>\n")); + } + cmap.push_str("endbfchar\n"); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + cmap +} + +pub struct PdfPageReference { + doc: PdfDocumentReference, + page: usize, +} + +impl PdfPageReference { + pub fn get_layer(&self, layer: PdfLayerIndex) -> PdfLayerReference { + PdfLayerReference { + doc: self.doc.clone(), + page: self.page, + layer: layer.0, + } + } + + /// Adds a layer drawn above the existing ones. + pub fn add_layer(&self, name: &str) -> PdfLayerReference { + let mut doc = self.doc.0.borrow_mut(); + let layers = &mut doc.pages[self.page].layers; + layers.push(LayerData { + name: name.to_string(), + operations: Vec::new(), + current_font: None, + }); + PdfLayerReference { + doc: self.doc.clone(), + page: self.page, + layer: layers.len() - 1, + } + } +} + +/// A layer of a page; all drawing goes through it. +#[derive(Clone)] +pub struct PdfLayerReference { + doc: PdfDocumentReference, + page: usize, + layer: usize, +} + +impl PdfLayerReference { + fn with_layer(&self, f: impl FnOnce(&mut LayerData) -> T) -> T { + let mut doc = self.doc.0.borrow_mut(); + f(&mut doc.pages[self.page].layers[self.layer]) + } + + fn push(&self, operator: &str, operands: Vec) { + self.add_operation(Operation::new(operator, operands)); + } + + pub fn add_operation(&self, operation: Operation) { + self.with_layer(|layer| layer.operations.push(operation)); + } + + pub fn save_graphics_state(&self) { + self.push("q", vec![]); + } + + pub fn restore_graphics_state(&self) { + self.push("Q", vec![]); + } + + pub fn set_ctm(&self, matrix: CurTransMat) { + let m = match matrix { + CurTransMat::Translate(x, y) => [1.0, 0.0, 0.0, 1.0, x.0, y.0], + CurTransMat::Raw(m) => m, + }; + self.push("cm", m.iter().map(|&v| v.into()).collect()); + } + + pub fn set_fill_color(&self, color: Color) { + let Color::Rgb(c) = color; + self.push("rg", vec![c.r.into(), c.g.into(), c.b.into()]); + } + + pub fn set_outline_color(&self, color: Color) { + let Color::Rgb(c) = color; + self.push("RG", vec![c.r.into(), c.g.into(), c.b.into()]); + } + + pub fn set_outline_thickness(&self, thickness: f32) { + self.push("w", vec![thickness.into()]); + } + + pub fn set_line_cap_style(&self, style: LineCapStyle) { + let value = match style { + LineCapStyle::Butt => 0, + LineCapStyle::Round => 1, + }; + self.push("J", vec![Object::Integer(value)]); + } + + pub fn set_line_join_style(&self, style: LineJoinStyle) { + let value = match style { + LineJoinStyle::Miter => 0, + LineJoinStyle::Round => 1, + }; + self.push("j", vec![Object::Integer(value)]); + } + + pub fn add_line(&self, line: Line) { + if line.points.is_empty() { + return; + } + let mut operations = path_operations(&line.points); + operations.push(Operation::new( + if line.is_closed { "s" } else { "S" }, + vec![], + )); + self.with_layer(|layer| layer.operations.extend(operations)); + } + + pub fn add_polygon(&self, polygon: Polygon) { + let mut operations: Vec = polygon + .rings + .iter() + .filter(|ring| !ring.is_empty()) + .flat_map(|ring| path_operations(ring)) + .collect(); + if operations.is_empty() { + return; + } + operations.push(Operation::new( + match polygon.mode { + PaintMode::Fill => "f", + PaintMode::Stroke => "s", + }, + vec![], + )); + self.with_layer(|layer| layer.operations.extend(operations)); + } + + /// Draws `image` into the unit square of the current coordinate system. + pub fn add_image(&self, image: ImageXObject) { + let mut doc = self.doc.0.borrow_mut(); + let page = &mut doc.pages[self.page]; + page.images.push(image); + let name = format!("X{}", page.images.len() - 1); + page.layers[self.layer] + .operations + .push(Operation::new("Do", vec![Object::Name(name.into_bytes())])); + } + + pub fn begin_text_section(&self) { + self.push("BT", vec![]); + } + + pub fn end_text_section(&self) { + self.push("ET", vec![]); + self.with_layer(|layer| layer.current_font = None); + } + + pub fn set_font(&self, font: &IndirectFontRef, size: f32) { + self.push( + "Tf", + vec![Object::Name(font.resource_name().into_bytes()), size.into()], + ); + self.with_layer(|layer| layer.current_font = Some(font.index)); + } + + pub fn set_text_matrix(&self, matrix: TextMatrix) { + let TextMatrix::Translate(x, y) = matrix; + self.push( + "Tm", + vec![ + 1.into(), + 0.into(), + 0.into(), + 1.into(), + x.0.into(), + y.0.into(), + ], + ); + } + + /// Shows glyphs of the current font; each comes with a shift before it in 1/1000 em + /// (positive moves left, as in `TJ`). + pub fn write_positioned_codepoints(&self, glyphs: impl IntoIterator) { + let mut list = Vec::new(); + let mut used = Vec::new(); + for (shift, gid) in glyphs { + if shift != 0 { + list.push(Object::Integer(shift)); + } + list.push(Object::String( + gid.to_be_bytes().to_vec(), + StringFormat::Hexadecimal, + )); + used.push(gid); + } + let mut doc = self.doc.0.borrow_mut(); + let layer = &mut doc.pages[self.page].layers[self.layer]; + layer + .operations + .push(Operation::new("TJ", vec![Object::Array(list)])); + if let Some(font) = layer.current_font { + doc.fonts[font].used_glyphs.extend(used); + } + } +} + +/// `m`, `l` and `c` operations for a point list (see [`Line`] for the Bézier marking). +fn path_operations(points: &[(Point, bool)]) -> Vec { + let xy = |p: &Point| -> [Object; 2] { [p.x.0.into(), p.y.0.into()] }; + let mut operations = vec![Operation::new("m", xy(&points[0].0).to_vec())]; + let mut current = 1; + while current < points.len() { + let (previous, point) = (&points[current - 1], &points[current]); + if previous.1 && point.1 { + if let (Some(c2), Some(end)) = (points.get(current + 1), points.get(current + 2)) { + let operands = [xy(&point.0), xy(&c2.0), xy(&end.0)].concat(); + operations.push(Operation::new("c", operands)); + current += 3; + continue; + } + } + operations.push(Operation::new("l", xy(&point.0).to_vec())); + current += 1; + } + operations +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(points: &[(f32, f32, bool)]) -> Vec<(Point, bool)> { + points + .iter() + .map(|&(x, y, b)| (Point::new(Mm(x), Mm(y)), b)) + .collect() + } + + #[test] + fn test_path_operations_lines_and_curves() { + let ops = path_operations(&line(&[ + (0.0, 0.0, true), + (1.0, 0.0, true), + (2.0, 0.0, false), + (3.0, 0.0, false), + (4.0, 0.0, false), + ])); + let names: Vec<&str> = ops.iter().map(|o| o.operator.as_str()).collect(); + assert_eq!(names, ["m", "c", "l"]); + assert_eq!(ops[1].operands.len(), 6); + } + + #[test] + fn test_document_with_text_image_and_bookmarks() { + let (doc, page, layer) = PdfDocument::new("Test", Mm(210.0), Mm(297.0), "Background"); + let font = doc + .add_external_font(crate::text::TextFont::Sans.bytes()) + .unwrap(); + doc.add_bookmark("Übung – 1".to_string(), page); + let background = doc.get_page(page).get_layer(layer); + background.add_polygon(Polygon { + rings: vec![line(&[ + (0.0, 0.0, false), + (10.0, 0.0, false), + (10.0, 10.0, false), + ])], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }); + let text = doc.get_page(page).add_layer("Text"); + let face = crate::text::TextFont::Sans.face(); + let gids: Vec = "Hä".chars().map(|c| face.glyph_id(c).0).collect(); + text.begin_text_section(); + text.set_font(&font, 12.0); + text.write_positioned_codepoints(gids.iter().map(|&g| (0, g))); + text.end_text_section(); + text.add_image(ImageXObject { + width: Px(1), + height: Px(1), + color_space: ColorSpace::Rgb, + interpolate: true, + image_data: vec![255, 0, 0], + image_filter: None, + }); + let bytes = doc.save_to_bytes().unwrap(); + + let pdf = Document::load_mem(&bytes).unwrap(); + assert_eq!(pdf.get_pages().len(), 1); + let page_id = *pdf.get_pages().values().next().unwrap(); + let content = pdf.get_page_content(page_id); + let ops = Content::decode(&content).unwrap().operations; + let names: Vec<&str> = ops.iter().map(|o| o.operator.as_str()).collect(); + for expected in ["BDC", "f", "BT", "Tf", "TJ", "ET", "Do", "EMC"] { + assert!(names.contains(&expected), "{expected} missing in {names:?}"); + } + // The ToUnicode map knows both characters + let fonts = pdf.get_page_fonts(page_id).unwrap(); + let font = fonts.values().next().unwrap(); + let to_unicode = pdf + .get_object(font.get(b"ToUnicode").unwrap().as_reference().unwrap()) + .unwrap() + .as_stream() + .unwrap() + .decompressed_content() + .unwrap(); + let cmap = String::from_utf8(to_unicode).unwrap(); + assert!(cmap.contains("<0048>"), "{cmap}"); + assert!(cmap.contains("<00E4>"), "{cmap}"); + } +}