max / balanced_breakfast
14 files changed,
+882 insertions,
-3 deletions
| @@ -199,6 +199,7 @@ dependencies = [ | |||
| 199 | 199 | "bb-db", | |
| 200 | 200 | "bb-feed", | |
| 201 | 201 | "bb-interface", | |
| 202 | + | "bb-pdf", | |
| 202 | 203 | "chrono", | |
| 203 | 204 | "futures", | |
| 204 | 205 | "open", | |
| @@ -210,6 +211,7 @@ dependencies = [ | |||
| 210 | 211 | "serde_json", | |
| 211 | 212 | "sha2", | |
| 212 | 213 | "sqlx", | |
| 214 | + | "supernote-push", | |
| 213 | 215 | "synckit-client", | |
| 214 | 216 | "tagtree", | |
| 215 | 217 | "tauri", | |
| @@ -260,7 +262,7 @@ dependencies = [ | |||
| 260 | 262 | "futures", | |
| 261 | 263 | "keyring", | |
| 262 | 264 | "parking_lot", | |
| 263 | - | "pter", | |
| 265 | + | "pter 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", | |
| 264 | 266 | "rand 0.8.5", | |
| 265 | 267 | "readable-readability", | |
| 266 | 268 | "regex", | |
| @@ -317,6 +319,16 @@ dependencies = [ | |||
| 317 | 319 | ] | |
| 318 | 320 | ||
| 319 | 321 | [[package]] | |
| 322 | + | name = "bb-pdf" | |
| 323 | + | version = "0.3.1" | |
| 324 | + | dependencies = [ | |
| 325 | + | "printpdf", | |
| 326 | + | "pter 0.1.0", | |
| 327 | + | "pulldown-cmark", | |
| 328 | + | "thiserror 1.0.69", | |
| 329 | + | ] | |
| 330 | + | ||
| 331 | + | [[package]] | |
| 320 | 332 | name = "bitflags" | |
| 321 | 333 | version = "1.3.2" | |
| 322 | 334 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -380,6 +392,17 @@ dependencies = [ | |||
| 380 | 392 | ] | |
| 381 | 393 | ||
| 382 | 394 | [[package]] | |
| 395 | + | name = "bstr" | |
| 396 | + | version = "1.13.0" | |
| 397 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 398 | + | checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" | |
| 399 | + | dependencies = [ | |
| 400 | + | "memchr", | |
| 401 | + | "regex-automata", | |
| 402 | + | "serde_core", | |
| 403 | + | ] | |
| 404 | + | ||
| 405 | + | [[package]] | |
| 383 | 406 | name = "bumpalo" | |
| 384 | 407 | version = "3.19.1" | |
| 385 | 408 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2038,7 +2061,7 @@ dependencies = [ | |||
| 2038 | 2061 | "libc", | |
| 2039 | 2062 | "percent-encoding", | |
| 2040 | 2063 | "pin-project-lite", | |
| 2041 | - | "socket2", | |
| 2064 | + | "socket2 0.6.2", | |
| 2042 | 2065 | "system-configuration", | |
| 2043 | 2066 | "tokio", | |
| 2044 | 2067 | "tower-service", | |
| @@ -2189,6 +2212,16 @@ dependencies = [ | |||
| 2189 | 2212 | ] | |
| 2190 | 2213 | ||
| 2191 | 2214 | [[package]] | |
| 2215 | + | name = "if-addrs" | |
| 2216 | + | version = "0.13.4" | |
| 2217 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2218 | + | checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" | |
| 2219 | + | dependencies = [ | |
| 2220 | + | "libc", | |
| 2221 | + | "windows-sys 0.59.0", | |
| 2222 | + | ] | |
| 2223 | + | ||
| 2224 | + | [[package]] | |
| 2192 | 2225 | name = "indexmap" | |
| 2193 | 2226 | version = "1.9.3" | |
| 2194 | 2227 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2481,6 +2514,12 @@ dependencies = [ | |||
| 2481 | 2514 | ] | |
| 2482 | 2515 | ||
| 2483 | 2516 | [[package]] | |
| 2517 | + | name = "linked-hash-map" | |
| 2518 | + | version = "0.5.6" | |
| 2519 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2520 | + | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" | |
| 2521 | + | ||
| 2522 | + | [[package]] | |
| 2484 | 2523 | name = "linux-keyutils" | |
| 2485 | 2524 | version = "0.2.5" | |
| 2486 | 2525 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2518,6 +2557,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2518 | 2557 | checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" | |
| 2519 | 2558 | ||
| 2520 | 2559 | [[package]] | |
| 2560 | + | name = "lopdf" | |
| 2561 | + | version = "0.31.0" | |
| 2562 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2563 | + | checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" | |
| 2564 | + | dependencies = [ | |
| 2565 | + | "encoding_rs", | |
| 2566 | + | "flate2", | |
| 2567 | + | "itoa 1.0.17", | |
| 2568 | + | "linked-hash-map", | |
| 2569 | + | "log", | |
| 2570 | + | "md5", | |
| 2571 | + | "pom", | |
| 2572 | + | "time", | |
| 2573 | + | "weezl", | |
| 2574 | + | ] | |
| 2575 | + | ||
| 2576 | + | [[package]] | |
| 2521 | 2577 | name = "mac" | |
| 2522 | 2578 | version = "0.1.1" | |
| 2523 | 2579 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2627,6 +2683,25 @@ dependencies = [ | |||
| 2627 | 2683 | ] | |
| 2628 | 2684 | ||
| 2629 | 2685 | [[package]] | |
| 2686 | + | name = "md5" | |
| 2687 | + | version = "0.7.0" | |
| 2688 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2689 | + | checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" | |
| 2690 | + | ||
| 2691 | + | [[package]] | |
| 2692 | + | name = "mdns-sd" | |
| 2693 | + | version = "0.11.5" | |
| 2694 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2695 | + | checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" | |
| 2696 | + | dependencies = [ | |
| 2697 | + | "flume", | |
| 2698 | + | "if-addrs", | |
| 2699 | + | "log", | |
| 2700 | + | "polling", | |
| 2701 | + | "socket2 0.5.10", | |
| 2702 | + | ] | |
| 2703 | + | ||
| 2704 | + | [[package]] | |
| 2630 | 2705 | name = "memchr" | |
| 2631 | 2706 | version = "2.8.0" | |
| 2632 | 2707 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3182,6 +3257,15 @@ dependencies = [ | |||
| 3182 | 3257 | ] | |
| 3183 | 3258 | ||
| 3184 | 3259 | [[package]] | |
| 3260 | + | name = "owned_ttf_parser" | |
| 3261 | + | version = "0.19.0" | |
| 3262 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3263 | + | checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" | |
| 3264 | + | dependencies = [ | |
| 3265 | + | "ttf-parser", | |
| 3266 | + | ] | |
| 3267 | + | ||
| 3268 | + | [[package]] | |
| 3185 | 3269 | name = "pango" | |
| 3186 | 3270 | version = "0.18.3" | |
| 3187 | 3271 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3536,6 +3620,22 @@ dependencies = [ | |||
| 3536 | 3620 | ] | |
| 3537 | 3621 | ||
| 3538 | 3622 | [[package]] | |
| 3623 | + | name = "polling" | |
| 3624 | + | version = "2.8.0" | |
| 3625 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3626 | + | checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" | |
| 3627 | + | dependencies = [ | |
| 3628 | + | "autocfg", | |
| 3629 | + | "bitflags 1.3.2", | |
| 3630 | + | "cfg-if", | |
| 3631 | + | "concurrent-queue", | |
| 3632 | + | "libc", | |
| 3633 | + | "log", | |
| 3634 | + | "pin-project-lite", | |
| 3635 | + | "windows-sys 0.48.0", | |
| 3636 | + | ] | |
| 3637 | + | ||
| 3638 | + | [[package]] | |
| 3539 | 3639 | name = "poly1305" | |
| 3540 | 3640 | version = "0.8.0" | |
| 3541 | 3641 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3559,6 +3659,15 @@ dependencies = [ | |||
| 3559 | 3659 | ] | |
| 3560 | 3660 | ||
| 3561 | 3661 | [[package]] | |
| 3662 | + | name = "pom" | |
| 3663 | + | version = "3.4.0" | |
| 3664 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3665 | + | checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" | |
| 3666 | + | dependencies = [ | |
| 3667 | + | "bstr", | |
| 3668 | + | ] | |
| 3669 | + | ||
| 3670 | + | [[package]] | |
| 3562 | 3671 | name = "portable-atomic" | |
| 3563 | 3672 | version = "1.13.1" | |
| 3564 | 3673 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3595,6 +3704,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3595 | 3704 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" | |
| 3596 | 3705 | ||
| 3597 | 3706 | [[package]] | |
| 3707 | + | name = "printpdf" | |
| 3708 | + | version = "0.7.0" | |
| 3709 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3710 | + | checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" | |
| 3711 | + | dependencies = [ | |
| 3712 | + | "js-sys", | |
| 3713 | + | "lopdf", | |
| 3714 | + | "owned_ttf_parser", | |
| 3715 | + | "time", | |
| 3716 | + | ] | |
| 3717 | + | ||
| 3718 | + | [[package]] | |
| 3598 | 3719 | name = "proc-macro-crate" | |
| 3599 | 3720 | version = "1.3.1" | |
| 3600 | 3721 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3665,6 +3786,13 @@ dependencies = [ | |||
| 3665 | 3786 | [[package]] | |
| 3666 | 3787 | name = "pter" | |
| 3667 | 3788 | version = "0.1.0" | |
| 3789 | + | dependencies = [ | |
| 3790 | + | "scraper", | |
| 3791 | + | ] | |
| 3792 | + | ||
| 3793 | + | [[package]] | |
| 3794 | + | name = "pter" | |
| 3795 | + | version = "0.1.0" | |
| 3668 | 3796 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3669 | 3797 | checksum = "ecc3629f628e0e8d975cd206ef331ce598dd457d94cd609c96a6809934732859" | |
| 3670 | 3798 | dependencies = [ | |
| @@ -4744,6 +4872,16 @@ dependencies = [ | |||
| 4744 | 4872 | ||
| 4745 | 4873 | [[package]] | |
| 4746 | 4874 | name = "socket2" | |
| 4875 | + | version = "0.5.10" | |
| 4876 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4877 | + | checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" | |
| 4878 | + | dependencies = [ | |
| 4879 | + | "libc", | |
| 4880 | + | "windows-sys 0.52.0", | |
| 4881 | + | ] | |
| 4882 | + | ||
| 4883 | + | [[package]] | |
| 4884 | + | name = "socket2" | |
| 4747 | 4885 | version = "0.6.2" | |
| 4748 | 4886 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4749 | 4887 | checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" | |
| @@ -5106,6 +5244,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5106 | 5244 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |
| 5107 | 5245 | ||
| 5108 | 5246 | [[package]] | |
| 5247 | + | name = "supernote-push" | |
| 5248 | + | version = "0.1.0" | |
| 5249 | + | dependencies = [ | |
| 5250 | + | "mdns-sd", | |
| 5251 | + | "percent-encoding", | |
| 5252 | + | "serde", | |
| 5253 | + | "serde_json", | |
| 5254 | + | "thiserror 2.0.18", | |
| 5255 | + | "ureq", | |
| 5256 | + | ] | |
| 5257 | + | ||
| 5258 | + | [[package]] | |
| 5109 | 5259 | name = "swift-rs" | |
| 5110 | 5260 | version = "1.0.7" | |
| 5111 | 5261 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5813,7 +5963,7 @@ dependencies = [ | |||
| 5813 | 5963 | "libc", | |
| 5814 | 5964 | "mio", | |
| 5815 | 5965 | "pin-project-lite", | |
| 5816 | - | "socket2", | |
| 5966 | + | "socket2 0.6.2", | |
| 5817 | 5967 | "tokio-macros", | |
| 5818 | 5968 | "windows-sys 0.61.2", | |
| 5819 | 5969 | ] | |
| @@ -6105,6 +6255,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 6105 | 6255 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 6106 | 6256 | ||
| 6107 | 6257 | [[package]] | |
| 6258 | + | name = "ttf-parser" | |
| 6259 | + | version = "0.19.2" | |
| 6260 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6261 | + | checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" | |
| 6262 | + | ||
| 6263 | + | [[package]] | |
| 6108 | 6264 | name = "typeid" | |
| 6109 | 6265 | version = "1.0.3" | |
| 6110 | 6266 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -6618,6 +6774,12 @@ dependencies = [ | |||
| 6618 | 6774 | ] | |
| 6619 | 6775 | ||
| 6620 | 6776 | [[package]] | |
| 6777 | + | name = "weezl" | |
| 6778 | + | version = "0.1.12" | |
| 6779 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6780 | + | checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" | |
| 6781 | + | ||
| 6782 | + | [[package]] | |
| 6621 | 6783 | name = "whoami" | |
| 6622 | 6784 | version = "1.6.1" | |
| 6623 | 6785 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -5,6 +5,7 @@ members = [ | |||
| 5 | 5 | "crates/bb-core", | |
| 6 | 6 | "crates/bb-feed", | |
| 7 | 7 | "crates/bb-db", | |
| 8 | + | "crates/bb-pdf", | |
| 8 | 9 | "src-tauri", | |
| 9 | 10 | ] | |
| 10 | 11 | default-members = ["src-tauri"] | |
| @@ -57,6 +58,10 @@ bb-interface = { path = "crates/bb-interface" } | |||
| 57 | 58 | bb-core = { path = "crates/bb-core" } | |
| 58 | 59 | bb-feed = { path = "crates/bb-feed" } | |
| 59 | 60 | bb-db = { path = "crates/bb-db" } | |
| 61 | + | bb-pdf = { path = "crates/bb-pdf" } | |
| 62 | + | ||
| 63 | + | # External sibling crates (shared under ~/Code/Libraries) | |
| 64 | + | supernote-push = { path = "../../Libraries/supernote-push" } | |
| 60 | 65 | synckit-client = { path = "../../MNW/shared/synckit-client" } | |
| 61 | 66 | tauri = "2.10.2" | |
| 62 | 67 | tauri-plugin-updater = "2" |
| @@ -0,0 +1,12 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "bb-pdf" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | license-file.workspace = true | |
| 6 | + | description = "Render Balanced Breakfast article HTML to a Supernote-friendly PDF." | |
| 7 | + | ||
| 8 | + | [dependencies] | |
| 9 | + | pter = { path = "../../../../Libraries/pter" } | |
| 10 | + | pulldown-cmark = { version = "0.12", default-features = false } | |
| 11 | + | printpdf = { version = "0.7" } | |
| 12 | + | thiserror = { workspace = true } |
| @@ -0,0 +1,18 @@ | |||
| 1 | + | //! Render an article HTML file to PDF on disk. | |
| 2 | + | //! | |
| 3 | + | //! cargo run --example render -- <input.html> <output.pdf> [title] | |
| 4 | + | ||
| 5 | + | use std::path::PathBuf; | |
| 6 | + | ||
| 7 | + | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 8 | + | let mut args = std::env::args().skip(1); | |
| 9 | + | let input: PathBuf = args.next().ok_or("usage: render <input.html> <output.pdf> [title]")?.into(); | |
| 10 | + | let output: PathBuf = args.next().ok_or("missing <output.pdf>")?.into(); | |
| 11 | + | let title = args.next().unwrap_or_else(|| "Article".to_string()); | |
| 12 | + | ||
| 13 | + | let html = std::fs::read_to_string(&input)?; | |
| 14 | + | let bytes = bb_pdf::render_article(&html, &title, None)?; | |
| 15 | + | std::fs::write(&output, &bytes)?; | |
| 16 | + | println!("wrote {} bytes to {}", bytes.len(), output.display()); | |
| 17 | + | Ok(()) | |
| 18 | + | } |
| @@ -0,0 +1,76 @@ | |||
| 1 | + | //! Page geometry and text-width estimation. | |
| 2 | + | //! | |
| 3 | + | //! We use approximate Helvetica metrics rather than pulling real AFM tables | |
| 4 | + | //! or a font-shaping crate. The approximation slightly overcounts width so | |
| 5 | + | //! we err on the side of shorter lines rather than clipping the right edge. | |
| 6 | + | ||
| 7 | + | use printpdf::Mm; | |
| 8 | + | ||
| 9 | + | pub const PAGE_W: Mm = Mm(148.0); | |
| 10 | + | pub const PAGE_H: Mm = Mm(210.0); | |
| 11 | + | pub const MARGIN: Mm = Mm(14.0); | |
| 12 | + | ||
| 13 | + | pub const BODY_PT: f32 = 11.0; | |
| 14 | + | pub const H1_PT: f32 = 20.0; | |
| 15 | + | pub const H2_PT: f32 = 16.0; | |
| 16 | + | pub const H3_PT: f32 = 13.0; | |
| 17 | + | pub const MONO_PT: f32 = 10.0; | |
| 18 | + | ||
| 19 | + | pub const LINE_GAP: f32 = 1.35; | |
| 20 | + | pub const BLOCK_GAP_MM: f32 = 3.5; | |
| 21 | + | pub const HEADING_GAP_MM: f32 = 5.5; | |
| 22 | + | pub const LIST_INDENT_MM: f32 = 6.0; | |
| 23 | + | pub const QUOTE_INDENT_MM: f32 = 5.0; | |
| 24 | + | ||
| 25 | + | pub fn content_width_mm() -> f32 { | |
| 26 | + | PAGE_W.0 - MARGIN.0 * 2.0 | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub fn content_top_mm() -> f32 { | |
| 30 | + | PAGE_H.0 - MARGIN.0 | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | pub fn content_bottom_mm() -> f32 { | |
| 34 | + | MARGIN.0 | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Approximate width in mm of `text` rendered in Helvetica at `pt`. | |
| 38 | + | /// | |
| 39 | + | /// Uses a per-family average advance width of 0.52em (bold slightly wider). | |
| 40 | + | /// 1pt = 25.4/72 mm. | |
| 41 | + | pub fn text_width_mm(text: &str, pt: f32, bold: bool, mono: bool) -> f32 { | |
| 42 | + | let avg_em = if mono { | |
| 43 | + | 0.60 | |
| 44 | + | } else if bold { | |
| 45 | + | 0.56 | |
| 46 | + | } else { | |
| 47 | + | 0.52 | |
| 48 | + | }; | |
| 49 | + | let mm_per_pt = 25.4 / 72.0; | |
| 50 | + | text.chars().count() as f32 * pt * avg_em * mm_per_pt | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | pub fn line_height_mm(pt: f32) -> f32 { | |
| 54 | + | pt * LINE_GAP * 25.4 / 72.0 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[cfg(test)] | |
| 58 | + | mod tests { | |
| 59 | + | use super::*; | |
| 60 | + | ||
| 61 | + | #[test] | |
| 62 | + | fn width_grows_with_length_and_size() { | |
| 63 | + | let a = text_width_mm("hello", BODY_PT, false, false); | |
| 64 | + | let b = text_width_mm("hello world", BODY_PT, false, false); | |
| 65 | + | let c = text_width_mm("hello", H1_PT, false, false); | |
| 66 | + | assert!(b > a); | |
| 67 | + | assert!(c > a); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn bold_wider_than_regular() { | |
| 72 | + | let r = text_width_mm("hello", BODY_PT, false, false); | |
| 73 | + | let b = text_width_mm("hello", BODY_PT, true, false); | |
| 74 | + | assert!(b > r); | |
| 75 | + | } | |
| 76 | + | } |
| @@ -0,0 +1,21 @@ | |||
| 1 | + | //! Render a Balanced Breakfast article body (HTML) to a PDF suitable | |
| 2 | + | //! for reading on a Supernote e-ink tablet. | |
| 3 | + | //! | |
| 4 | + | //! Pipeline: HTML -> `pter` markdown -> `pulldown-cmark` events -> a small | |
| 5 | + | //! hand-rolled `printpdf` layout. A5 portrait, Helvetica, three levels of | |
| 6 | + | //! headings, bold, italic, bulleted / ordered lists, blockquotes. Images | |
| 7 | + | //! and tables are dropped for v1; code blocks render as monospace inline | |
| 8 | + | //! text with no syntax highlighting. | |
| 9 | + | ||
| 10 | + | mod layout; | |
| 11 | + | mod render; | |
| 12 | + | ||
| 13 | + | pub use render::render_article; | |
| 14 | + | ||
| 15 | + | #[derive(Debug, thiserror::Error)] | |
| 16 | + | pub enum Error { | |
| 17 | + | #[error("printpdf: {0}")] | |
| 18 | + | PrintPdf(String), | |
| 19 | + | #[error("empty article body")] | |
| 20 | + | Empty, | |
| 21 | + | } |
| @@ -0,0 +1,369 @@ | |||
| 1 | + | use std::io::BufWriter; | |
| 2 | + | ||
| 3 | + | use printpdf::{ | |
| 4 | + | BuiltinFont, IndirectFontRef, Mm, PdfDocument, PdfDocumentReference, PdfLayerIndex, | |
| 5 | + | PdfPageIndex, | |
| 6 | + | }; | |
| 7 | + | use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; | |
| 8 | + | ||
| 9 | + | use crate::{Error, layout::*}; | |
| 10 | + | ||
| 11 | + | pub fn render_article(html: &str, title: &str, url: Option<&str>) -> Result<Vec<u8>, Error> { | |
| 12 | + | let markdown = pter::convert(html); | |
| 13 | + | let markdown = markdown.trim(); | |
| 14 | + | if markdown.is_empty() { | |
| 15 | + | return Err(Error::Empty); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | let mut r = Renderer::new(title)?; | |
| 19 | + | if !title.trim().is_empty() { | |
| 20 | + | r.draw_heading(title, H1_PT); | |
| 21 | + | if let Some(u) = url { | |
| 22 | + | r.draw_paragraph(u, Style { italic: true, ..Style::default() }); | |
| 23 | + | } | |
| 24 | + | r.block_gap(); | |
| 25 | + | } | |
| 26 | + | r.render_markdown(markdown); | |
| 27 | + | r.finish() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Copy, Clone, Default)] | |
| 31 | + | struct Style { | |
| 32 | + | bold: bool, | |
| 33 | + | italic: bool, | |
| 34 | + | mono: bool, | |
| 35 | + | size_pt: f32, | |
| 36 | + | left_indent_mm: f32, | |
| 37 | + | bullet: Option<char>, | |
| 38 | + | number: Option<u64>, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl Style { | |
| 42 | + | fn body() -> Self { | |
| 43 | + | Self { size_pt: BODY_PT, ..Self::default() } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | struct Renderer { | |
| 48 | + | doc: PdfDocumentReference, | |
| 49 | + | page_idx: PdfPageIndex, | |
| 50 | + | layer_idx: PdfLayerIndex, | |
| 51 | + | fonts: Fonts, | |
| 52 | + | y_mm: f32, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | struct Fonts { | |
| 56 | + | regular: IndirectFontRef, | |
| 57 | + | bold: IndirectFontRef, | |
| 58 | + | italic: IndirectFontRef, | |
| 59 | + | mono: IndirectFontRef, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | impl Renderer { | |
| 63 | + | fn new(title: &str) -> Result<Self, Error> { | |
| 64 | + | let doc_title = if title.trim().is_empty() { "Balanced Breakfast" } else { title }; | |
| 65 | + | let (doc, page_idx, layer_idx) = PdfDocument::new(doc_title, PAGE_W, PAGE_H, "L1"); | |
| 66 | + | let regular = doc.add_builtin_font(BuiltinFont::Helvetica).map_err(err)?; | |
| 67 | + | let bold = doc.add_builtin_font(BuiltinFont::HelveticaBold).map_err(err)?; | |
| 68 | + | let italic = doc.add_builtin_font(BuiltinFont::HelveticaOblique).map_err(err)?; | |
| 69 | + | let mono = doc.add_builtin_font(BuiltinFont::Courier).map_err(err)?; | |
| 70 | + | Ok(Self { | |
| 71 | + | doc, | |
| 72 | + | page_idx, | |
| 73 | + | layer_idx, | |
| 74 | + | fonts: Fonts { regular, bold, italic, mono }, | |
| 75 | + | y_mm: content_top_mm(), | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | fn finish(self) -> Result<Vec<u8>, Error> { | |
| 80 | + | let mut buf = Vec::new(); | |
| 81 | + | self.doc.save(&mut BufWriter::new(&mut buf)).map_err(err)?; | |
| 82 | + | Ok(buf) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | fn render_markdown(&mut self, md: &str) { | |
| 86 | + | let opts = Options::ENABLE_STRIKETHROUGH; | |
| 87 | + | let mut parser = Parser::new_ext(md, opts).peekable(); | |
| 88 | + | let mut style_stack: Vec<Style> = vec![Style::body()]; | |
| 89 | + | let mut list_stack: Vec<Option<u64>> = Vec::new(); | |
| 90 | + | let mut buf = String::new(); | |
| 91 | + | ||
| 92 | + | while let Some(event) = parser.next() { | |
| 93 | + | match event { | |
| 94 | + | Event::Start(tag) => match tag { | |
| 95 | + | Tag::Heading { level, .. } => { | |
| 96 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 97 | + | self.block_gap_heading(); | |
| 98 | + | let pt = match level { | |
| 99 | + | HeadingLevel::H1 => H1_PT, | |
| 100 | + | HeadingLevel::H2 => H2_PT, | |
| 101 | + | _ => H3_PT, | |
| 102 | + | }; | |
| 103 | + | style_stack.push(Style { | |
| 104 | + | size_pt: pt, | |
| 105 | + | bold: true, | |
| 106 | + | ..*style_stack.last().unwrap() | |
| 107 | + | }); | |
| 108 | + | } | |
| 109 | + | Tag::Paragraph => { | |
| 110 | + | // nothing; paragraph text flushes on End(Paragraph) | |
| 111 | + | } | |
| 112 | + | Tag::BlockQuote(_) => { | |
| 113 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 114 | + | self.block_gap(); | |
| 115 | + | let mut s = *style_stack.last().unwrap(); | |
| 116 | + | s.italic = true; | |
| 117 | + | s.left_indent_mm += QUOTE_INDENT_MM; | |
| 118 | + | style_stack.push(s); | |
| 119 | + | } | |
| 120 | + | Tag::List(start) => { | |
| 121 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 122 | + | self.block_gap(); | |
| 123 | + | list_stack.push(start); | |
| 124 | + | } | |
| 125 | + | Tag::Item => { | |
| 126 | + | let mut s = *style_stack.last().unwrap(); | |
| 127 | + | s.left_indent_mm += LIST_INDENT_MM; | |
| 128 | + | match list_stack.last_mut() { | |
| 129 | + | Some(Some(n)) => { | |
| 130 | + | s.number = Some(*n); | |
| 131 | + | *n += 1; | |
| 132 | + | } | |
| 133 | + | _ => s.bullet = Some('\u{2022}'), | |
| 134 | + | } | |
| 135 | + | style_stack.push(s); | |
| 136 | + | } | |
| 137 | + | Tag::Emphasis => { | |
| 138 | + | let mut s = *style_stack.last().unwrap(); | |
| 139 | + | s.italic = true; | |
| 140 | + | style_stack.push(s); | |
| 141 | + | } | |
| 142 | + | Tag::Strong => { | |
| 143 | + | let mut s = *style_stack.last().unwrap(); | |
| 144 | + | s.bold = true; | |
| 145 | + | style_stack.push(s); | |
| 146 | + | } | |
| 147 | + | Tag::CodeBlock(_) => { | |
| 148 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 149 | + | self.block_gap(); | |
| 150 | + | let mut s = *style_stack.last().unwrap(); | |
| 151 | + | s.mono = true; | |
| 152 | + | s.size_pt = MONO_PT; | |
| 153 | + | style_stack.push(s); | |
| 154 | + | } | |
| 155 | + | Tag::Link { .. } | Tag::Image { .. } => { | |
| 156 | + | // Render link text inline; drop images entirely. | |
| 157 | + | } | |
| 158 | + | _ => {} | |
| 159 | + | }, | |
| 160 | + | Event::End(end) => match end { | |
| 161 | + | TagEnd::Heading(_) => { | |
| 162 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 163 | + | style_stack.pop(); | |
| 164 | + | } | |
| 165 | + | TagEnd::Paragraph => { | |
| 166 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 167 | + | self.block_gap(); | |
| 168 | + | } | |
| 169 | + | TagEnd::BlockQuote(_) | TagEnd::CodeBlock => { | |
| 170 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 171 | + | self.block_gap(); | |
| 172 | + | style_stack.pop(); | |
| 173 | + | } | |
| 174 | + | TagEnd::List(_) => { | |
| 175 | + | list_stack.pop(); | |
| 176 | + | } | |
| 177 | + | TagEnd::Item => { | |
| 178 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 179 | + | style_stack.pop(); | |
| 180 | + | } | |
| 181 | + | TagEnd::Emphasis | TagEnd::Strong => { | |
| 182 | + | // Style-scoped runs are simplified: we buffer per-paragraph | |
| 183 | + | // as a single run of the outer style, so nested bold/italic | |
| 184 | + | // is lost. This is a v1 limitation. | |
| 185 | + | style_stack.pop(); | |
| 186 | + | } | |
| 187 | + | _ => {} | |
| 188 | + | }, | |
| 189 | + | Event::Text(t) | Event::Code(t) => { | |
| 190 | + | if !buf.is_empty() && !buf.ends_with(char::is_whitespace) { | |
| 191 | + | buf.push(' '); | |
| 192 | + | } | |
| 193 | + | buf.push_str(&t); | |
| 194 | + | } | |
| 195 | + | Event::SoftBreak => buf.push(' '), | |
| 196 | + | Event::HardBreak => { | |
| 197 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 198 | + | } | |
| 199 | + | _ => {} | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | self.flush_paragraph(&mut buf, *style_stack.last().unwrap()); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn flush_paragraph(&mut self, buf: &mut String, style: Style) { | |
| 206 | + | let text = buf.trim(); | |
| 207 | + | if text.is_empty() { | |
| 208 | + | buf.clear(); | |
| 209 | + | return; | |
| 210 | + | } | |
| 211 | + | self.draw_paragraph(text, style); | |
| 212 | + | buf.clear(); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | fn draw_heading(&mut self, text: &str, pt: f32) { | |
| 216 | + | let style = Style { size_pt: pt, bold: true, ..Style::default() }; | |
| 217 | + | self.draw_paragraph(text, style); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | fn draw_paragraph(&mut self, text: &str, style: Style) { | |
| 221 | + | let leader = if let Some(n) = style.number { | |
| 222 | + | format!("{n}. ") | |
| 223 | + | } else if let Some(c) = style.bullet { | |
| 224 | + | format!("{c} ") | |
| 225 | + | } else { | |
| 226 | + | String::new() | |
| 227 | + | }; | |
| 228 | + | ||
| 229 | + | let line_h = line_height_mm(if style.size_pt > 0.0 { style.size_pt } else { BODY_PT }); | |
| 230 | + | let text_indent = style.left_indent_mm; | |
| 231 | + | let text_width = content_width_mm() - text_indent; | |
| 232 | + | ||
| 233 | + | let mut first = true; | |
| 234 | + | for line in wrap(text, style, text_width) { | |
| 235 | + | self.ensure_room(line_h); | |
| 236 | + | let x = MARGIN.0 + text_indent; | |
| 237 | + | let font = self.font_for(style); | |
| 238 | + | let layer = self.doc.get_page(self.page_idx).get_layer(self.layer_idx); | |
| 239 | + | let baseline_y = self.y_mm - style.size_pt * 25.4 / 72.0; | |
| 240 | + | ||
| 241 | + | if first && !leader.is_empty() { | |
| 242 | + | layer.use_text( | |
| 243 | + | &leader, | |
| 244 | + | style.size_pt, | |
| 245 | + | Mm(x - LIST_INDENT_MM), | |
| 246 | + | Mm(baseline_y), | |
| 247 | + | font, | |
| 248 | + | ); | |
| 249 | + | } | |
| 250 | + | layer.use_text(&line, style.size_pt, Mm(x), Mm(baseline_y), font); | |
| 251 | + | self.y_mm -= line_h; | |
| 252 | + | first = false; | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | fn font_for(&self, style: Style) -> &IndirectFontRef { | |
| 257 | + | if style.mono { | |
| 258 | + | &self.fonts.mono | |
| 259 | + | } else if style.bold { | |
| 260 | + | &self.fonts.bold | |
| 261 | + | } else if style.italic { | |
| 262 | + | &self.fonts.italic | |
| 263 | + | } else { | |
| 264 | + | &self.fonts.regular | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | fn ensure_room(&mut self, needed_mm: f32) { | |
| 269 | + | if self.y_mm - needed_mm < content_bottom_mm() { | |
| 270 | + | let (page, layer) = self.doc.add_page(PAGE_W, PAGE_H, "L1"); | |
| 271 | + | self.page_idx = page; | |
| 272 | + | self.layer_idx = layer; | |
| 273 | + | self.y_mm = content_top_mm(); | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | fn block_gap(&mut self) { | |
| 278 | + | self.y_mm -= BLOCK_GAP_MM; | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | fn block_gap_heading(&mut self) { | |
| 282 | + | self.y_mm -= HEADING_GAP_MM; | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | fn wrap(text: &str, style: Style, max_width_mm: f32) -> Vec<String> { | |
| 287 | + | let mut lines: Vec<String> = Vec::new(); | |
| 288 | + | let mut cur = String::new(); | |
| 289 | + | for word in text.split_whitespace() { | |
| 290 | + | let candidate = if cur.is_empty() { | |
| 291 | + | word.to_string() | |
| 292 | + | } else { | |
| 293 | + | format!("{cur} {word}") | |
| 294 | + | }; | |
| 295 | + | if text_width_mm(&candidate, style.size_pt, style.bold, style.mono) > max_width_mm | |
| 296 | + | && !cur.is_empty() | |
| 297 | + | { | |
| 298 | + | lines.push(std::mem::take(&mut cur)); | |
| 299 | + | cur.push_str(word); | |
| 300 | + | } else { | |
| 301 | + | cur = candidate; | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | if !cur.is_empty() { | |
| 305 | + | lines.push(cur); | |
| 306 | + | } | |
| 307 | + | if lines.is_empty() { | |
| 308 | + | lines.push(String::new()); | |
| 309 | + | } | |
| 310 | + | lines | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | fn err<E: std::fmt::Display>(e: E) -> Error { | |
| 314 | + | Error::PrintPdf(e.to_string()) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[cfg(test)] | |
| 318 | + | mod tests { | |
| 319 | + | use super::*; | |
| 320 | + | ||
| 321 | + | #[test] | |
| 322 | + | fn wraps_long_paragraph() { | |
| 323 | + | let text = "one two three four five six seven eight nine ten eleven twelve"; | |
| 324 | + | let lines = wrap(text, Style::body(), 30.0); | |
| 325 | + | assert!(lines.len() > 1); | |
| 326 | + | for l in &lines { | |
| 327 | + | assert!(!l.is_empty()); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn wrap_single_word_never_empties() { | |
| 333 | + | let lines = wrap("hello", Style::body(), 200.0); | |
| 334 | + | assert_eq!(lines, vec!["hello".to_string()]); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 338 | + | fn empty_body_is_error() { | |
| 339 | + | let out = render_article("<p></p>", "t", None); | |
| 340 | + | assert!(matches!(out, Err(Error::Empty))); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[test] | |
| 344 | + | fn end_to_end_produces_pdf_bytes() { | |
| 345 | + | let html = r#" | |
| 346 | + | <h1>Hello</h1> | |
| 347 | + | <p>This is a <strong>bold</strong> paragraph with an <em>italic</em> word.</p> | |
| 348 | + | <ul><li>one</li><li>two</li></ul> | |
| 349 | + | <blockquote>Quoted material.</blockquote> | |
| 350 | + | "#; | |
| 351 | + | let bytes = render_article(html, "Test Article", Some("https://example.com/a")).unwrap(); | |
| 352 | + | assert!(bytes.starts_with(b"%PDF")); | |
| 353 | + | assert!(bytes.len() > 1000); | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[test] | |
| 357 | + | fn long_article_produces_multiple_pages() { | |
| 358 | + | let mut html = String::from("<h1>Long</h1>"); | |
| 359 | + | for i in 0..200 { | |
| 360 | + | html.push_str(&format!( | |
| 361 | + | "<p>Paragraph number {i}. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>" | |
| 362 | + | )); | |
| 363 | + | } | |
| 364 | + | let bytes = render_article(&html, "Long", None).unwrap(); | |
| 365 | + | assert!(bytes.starts_with(b"%PDF")); | |
| 366 | + | // Very rough sanity check that the doc grew past a single page's worth | |
| 367 | + | assert!(bytes.len() > 8000); | |
| 368 | + | } | |
| 369 | + | } |
| @@ -20,6 +20,10 @@ bb-interface.workspace = true | |||
| 20 | 20 | bb-core.workspace = true | |
| 21 | 21 | bb-feed.workspace = true | |
| 22 | 22 | bb-db.workspace = true | |
| 23 | + | bb-pdf.workspace = true | |
| 24 | + | ||
| 25 | + | # Supernote push (LAN e-ink upload) | |
| 26 | + | supernote-push.workspace = true | |
| 23 | 27 | ||
| 24 | 28 | # Tauri | |
| 25 | 29 | tauri = { version = "2.10.2", features = [] } |
| @@ -69,6 +69,13 @@ | |||
| 69 | 69 | * @returns {Promise<number>} Count of items marked read. | |
| 70 | 70 | */ | |
| 71 | 71 | markAllRead: (sourceId) => invoke('mark_all_read', { sourceId: sourceId || null }), | |
| 72 | + | /** | |
| 73 | + | * Render the item as PDF and push it to the configured Supernote. | |
| 74 | + | * Fails if the item has no eligible body or Supernote is unconfigured. | |
| 75 | + | * @param {string} id - Item external_id. | |
| 76 | + | * @returns {Promise<void>} | |
| 77 | + | */ | |
| 78 | + | pushToSupernote: (id) => invoke('push_item_to_supernote', { id }), | |
| 72 | 79 | }, | |
| 73 | 80 | ||
| 74 | 81 | // --- Plugins: Rhai busser plugins --- |
| @@ -132,6 +132,14 @@ | |||
| 132 | 132 | actions.appendChild(openBtn); | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | + | if (item.supernoteEligible) { | |
| 136 | + | const snBtn = document.createElement('button'); | |
| 137 | + | snBtn.className = 'btn'; | |
| 138 | + | snBtn.textContent = 'Send to Supernote'; | |
| 139 | + | snBtn.addEventListener('click', pushToSupernote); | |
| 140 | + | actions.appendChild(snBtn); | |
| 141 | + | } | |
| 142 | + | ||
| 135 | 143 | // Plugin-declared custom action buttons | |
| 136 | 144 | if (item.actions && item.actions.length > 0) { | |
| 137 | 145 | for (const action of item.actions) { | |
| @@ -227,6 +235,22 @@ | |||
| 227 | 235 | } | |
| 228 | 236 | ||
| 229 | 237 | /** | |
| 238 | + | * Push the current item to the configured Supernote as a PDF. | |
| 239 | + | * Surfaces backend errors (missing config, empty body, unreachable device) | |
| 240 | + | * via a toast. | |
| 241 | + | */ | |
| 242 | + | async function pushToSupernote() { | |
| 243 | + | if (!currentItem) return; | |
| 244 | + | BB.ui.showToast('Rendering and pushing to Supernote...'); | |
| 245 | + | try { | |
| 246 | + | await BB.api.items.pushToSupernote(currentItem.id); | |
| 247 | + | BB.ui.showToast('Sent to Supernote', 'success'); | |
| 248 | + | } catch (err) { | |
| 249 | + | BB.ui.showToast('Supernote push failed: ' + getErrorMessage(err), 'error'); | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /** | |
| 230 | 254 | * Open the current item's URL in the system browser via Tauri shell API. | |
| 231 | 255 | * No-op if no item is selected or the item has no URL. | |
| 232 | 256 | */ |
| @@ -99,6 +99,9 @@ pub struct ItemDetailResponse { | |||
| 99 | 99 | pub is_read: bool, | |
| 100 | 100 | /// Whether the user has starred/favourited this item. | |
| 101 | 101 | pub is_starred: bool, | |
| 102 | + | /// Whether the body is worth converting to PDF and pushing to a Supernote. | |
| 103 | + | /// Frontend uses this to gate the "Send to Supernote" button. | |
| 104 | + | pub supernote_eligible: bool, | |
| 102 | 105 | } | |
| 103 | 106 | ||
| 104 | 107 | /// Paginated list of item summaries. | |
| @@ -202,6 +205,7 @@ fn item_to_detail(item: &bb_db::DbFeedItem) -> ItemDetailResponse { | |||
| 202 | 205 | }).collect(), | |
| 203 | 206 | is_read: item.is_read, | |
| 204 | 207 | is_starred: item.is_starred, | |
| 208 | + | supernote_eligible: crate::commands::supernote::is_eligible(item.body.as_deref()), | |
| 205 | 209 | } | |
| 206 | 210 | } | |
| 207 | 211 |
| @@ -8,6 +8,7 @@ pub mod opml; | |||
| 8 | 8 | pub mod plugins; | |
| 9 | 9 | pub mod query_feeds; | |
| 10 | 10 | pub mod sources; | |
| 11 | + | pub mod supernote; | |
| 11 | 12 | pub mod sync; | |
| 12 | 13 | pub mod themes; | |
| 13 | 14 | ||
| @@ -19,5 +20,6 @@ pub use opml::*; | |||
| 19 | 20 | pub use plugins::*; | |
| 20 | 21 | pub use query_feeds::*; | |
| 21 | 22 | pub use sources::*; | |
| 23 | + | pub use supernote::*; | |
| 22 | 24 | pub use sync::*; | |
| 23 | 25 | pub use themes::*; |
| @@ -0,0 +1,174 @@ | |||
| 1 | + | //! Push an article to a Ratta Supernote via the local Browse & Access API. | |
| 2 | + | //! | |
| 3 | + | //! Config lives at `<app_data_dir>/supernote.toml`: | |
| 4 | + | //! | |
| 5 | + | //! ```toml | |
| 6 | + | //! host = "192.168.0.18" | |
| 7 | + | //! remote_dir = "Document/balanced_breakfast" | |
| 8 | + | //! # passcode = "..." | |
| 9 | + | //! ``` | |
| 10 | + | //! | |
| 11 | + | //! Missing / unparseable file yields a `bad_request` from the push command, | |
| 12 | + | //! so the frontend can surface a helpful message directing the user to the | |
| 13 | + | //! config path. | |
| 14 | + | ||
| 15 | + | use std::path::Path; | |
| 16 | + | use std::sync::Arc; | |
| 17 | + | ||
| 18 | + | use bb_db::ItemId; | |
| 19 | + | use serde::Deserialize; | |
| 20 | + | use tauri::State; | |
| 21 | + | use tracing::instrument; | |
| 22 | + | ||
| 23 | + | use crate::commands::error::ApiError; | |
| 24 | + | use crate::state::AppState; | |
| 25 | + | ||
| 26 | + | const CONFIG_FILE: &str = "supernote.toml"; | |
| 27 | + | const DEFAULT_REMOTE_DIR: &str = "Document/balanced_breakfast"; | |
| 28 | + | const MIN_BODY_CHARS: usize = 200; | |
| 29 | + | ||
| 30 | + | #[derive(Deserialize)] | |
| 31 | + | struct SupernoteFile { | |
| 32 | + | host: String, | |
| 33 | + | #[serde(default)] | |
| 34 | + | remote_dir: Option<String>, | |
| 35 | + | #[serde(default)] | |
| 36 | + | passcode: Option<String>, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// True iff the item body is worth converting to PDF and pushing. | |
| 40 | + | /// | |
| 41 | + | /// The bar is intentionally low: any non-empty body over `MIN_BODY_CHARS` | |
| 42 | + | /// characters qualifies. Short bodies (bare HN titles, RSS-summary blurbs) | |
| 43 | + | /// aren't worth a page turn. | |
| 44 | + | pub fn is_eligible(body: Option<&str>) -> bool { | |
| 45 | + | body.map(|b| b.trim().chars().count() >= MIN_BODY_CHARS) | |
| 46 | + | .unwrap_or(false) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | fn load(data_dir: &Path) -> Result<SupernoteFile, ApiError> { | |
| 50 | + | let path = data_dir.join(CONFIG_FILE); | |
| 51 | + | let raw = std::fs::read_to_string(&path).map_err(|_| { | |
| 52 | + | ApiError::bad_request(format!( | |
| 53 | + | "Supernote not configured. Create {} with `host = \"<ip>\"`.", | |
| 54 | + | path.display() | |
| 55 | + | )) | |
| 56 | + | })?; | |
| 57 | + | toml::from_str::<SupernoteFile>(&raw) | |
| 58 | + | .map_err(|e| ApiError::bad_request(format!("Malformed {CONFIG_FILE}: {e}"))) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Render the item's HTML body to PDF and upload to the configured Supernote. | |
| 62 | + | #[tauri::command] | |
| 63 | + | #[instrument(skip_all)] | |
| 64 | + | pub async fn push_item_to_supernote( | |
| 65 | + | state: State<'_, Arc<AppState>>, | |
| 66 | + | id: String, | |
| 67 | + | ) -> Result<(), ApiError> { | |
| 68 | + | let item_id: ItemId = id | |
| 69 | + | .parse() | |
| 70 | + | .map_err(|_| ApiError::bad_request("Invalid item ID"))?; | |
| 71 | + | let db = state.orchestrator.database(); | |
| 72 | + | let item = db | |
| 73 | + | .items() | |
| 74 | + | .get(item_id) | |
| 75 | + | .await? | |
| 76 | + | .ok_or_else(|| ApiError::not_found(format!("Item {} not found", id)))?; | |
| 77 | + | ||
| 78 | + | let body = item.body.clone().ok_or_else(|| { | |
| 79 | + | ApiError::bad_request("Item has no body content to push") | |
| 80 | + | })?; | |
| 81 | + | if !is_eligible(Some(&body)) { | |
| 82 | + | return Err(ApiError::bad_request( | |
| 83 | + | "Item body is too short to be worth pushing", | |
| 84 | + | )); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | let cfg = load(&state.data_dir)?; | |
| 88 | + | let remote_dir = cfg | |
| 89 | + | .remote_dir | |
| 90 | + | .unwrap_or_else(|| DEFAULT_REMOTE_DIR.to_string()); | |
| 91 | + | let title = item | |
| 92 | + | .title | |
| 93 | + | .clone() | |
| 94 | + | .unwrap_or_else(|| item.bite_text.clone()); | |
| 95 | + | let url = item.url.clone(); | |
| 96 | + | let filename = pdf_filename(&title, &item.published_at); | |
| 97 | + | ||
| 98 | + | let bytes = tokio::task::spawn_blocking(move || { | |
| 99 | + | bb_pdf::render_article(&body, &title, url.as_deref()) | |
| 100 | + | }) | |
| 101 | + | .await | |
| 102 | + | .map_err(|e| ApiError::internal(format!("Render task join error: {e}")))? | |
| 103 | + | .map_err(|e| ApiError::internal(format!("PDF render failed: {e}")))?; | |
| 104 | + | ||
| 105 | + | let host = cfg.host.clone(); | |
| 106 | + | let passcode = cfg.passcode.clone(); | |
| 107 | + | tokio::task::spawn_blocking(move || { | |
| 108 | + | let mut sn = supernote_push::Supernote::at(&host); | |
| 109 | + | if let Some(pc) = passcode { | |
| 110 | + | sn = sn.with_passcode(pc); | |
| 111 | + | } | |
| 112 | + | sn.push_pdf(&remote_dir, &filename, &bytes) | |
| 113 | + | }) | |
| 114 | + | .await | |
| 115 | + | .map_err(|e| ApiError::internal(format!("Push task join error: {e}")))? | |
| 116 | + | .map_err(|e| ApiError::plugin(format!("Supernote push failed: {e}")))?; | |
| 117 | + | ||
| 118 | + | Ok(()) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | fn pdf_filename(title: &str, published_at: &str) -> String { | |
| 122 | + | let date = published_at | |
| 123 | + | .split_whitespace() | |
| 124 | + | .next() | |
| 125 | + | .unwrap_or(published_at); | |
| 126 | + | let slug: String = title | |
| 127 | + | .chars() | |
| 128 | + | .map(|c| { | |
| 129 | + | if c.is_ascii_alphanumeric() { | |
| 130 | + | c.to_ascii_lowercase() | |
| 131 | + | } else { | |
| 132 | + | '-' | |
| 133 | + | } | |
| 134 | + | }) | |
| 135 | + | .collect(); | |
| 136 | + | let slug: String = slug | |
| 137 | + | .split('-') | |
| 138 | + | .filter(|s| !s.is_empty()) | |
| 139 | + | .collect::<Vec<_>>() | |
| 140 | + | .join("-"); | |
| 141 | + | let slug = if slug.is_empty() { "article" } else { &slug }; | |
| 142 | + | let slug: String = slug.chars().take(60).collect(); | |
| 143 | + | format!("{date}_{slug}.pdf") | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[cfg(test)] | |
| 147 | + | mod tests { | |
| 148 | + | use super::*; | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn eligibility_rejects_short_and_missing() { | |
| 152 | + | assert!(!is_eligible(None)); | |
| 153 | + | assert!(!is_eligible(Some(""))); | |
| 154 | + | assert!(!is_eligible(Some("short"))); | |
| 155 | + | assert!(!is_eligible(Some(&"x".repeat(199)))); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[test] | |
| 159 | + | fn eligibility_accepts_prose() { | |
| 160 | + | assert!(is_eligible(Some(&"lorem ipsum ".repeat(50)))); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn filename_is_sluggy_and_dated() { | |
| 165 | + | let f = pdf_filename("Why E-Ink Rewards Slow Reading!", "2026-07-19 08:00:00"); | |
| 166 | + | assert_eq!(f, "2026-07-19_why-e-ink-rewards-slow-reading.pdf"); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn filename_falls_back_when_title_is_symbols() { | |
| 171 | + | let f = pdf_filename("///", "2026-07-19"); | |
| 172 | + | assert_eq!(f, "2026-07-19_article.pdf"); | |
| 173 | + | } | |
| 174 | + | } |
| @@ -128,6 +128,7 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> { | |||
| 128 | 128 | commands::is_bookmarked, | |
| 129 | 129 | commands::get_bookmark_count, | |
| 130 | 130 | commands::export_bookmark_html, | |
| 131 | + | commands::push_item_to_supernote, | |
| 131 | 132 | ]) | |
| 132 | 133 | } | |
| 133 | 134 |