Skip to main content

max / balanced_breakfast

detail: Send-to-Supernote button pushes article as PDF Adds a new workspace crate `bb-pdf` that converts a sanitized-HTML article body to an A5 PDF via pter (HTML->markdown), pulldown-cmark, and a small hand-rolled printpdf layout with headings, bold/italic, bulleted/ordered lists, and blockquotes. Images and tables are dropped for v1; code blocks render as inline monospace. A new `push_item_to_supernote` Tauri command loads the item, renders the PDF off the async runtime via spawn_blocking, and uploads to a Ratta Supernote via the `supernote-push` crate. Config file at `<app_data_dir>/supernote.toml` (`host = "..."`, optional `remote_dir` and `passcode`); a missing/malformed file produces a bad_request that directs the user to the exact path. `ItemDetailResponse` gains a `supernoteEligible` bool (true when body is >= 200 chars). The frontend detail panel shows a `Send to Supernote` button only for eligible items and surfaces failures via a toast. Pipeline verified in isolation: bb-pdf renders a real article to a 5998-byte PDF, and supernote-push has already been verified against the Nomad in the prior commit. End-to-end (button -> device) still needs a manual pass with Wi-Fi Transfer on. Pre-existing `theme_resolves_intents` test failure on main is unrelated and is not touched by this change.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 14:21 UTC
Signed with PGP, not checked
Commit: 995f47ec668f27127ddd19c839cb4dc13b474ee8
Parent: 287b892
14 files changed, +882 insertions, -3 deletions
M Cargo.lock +165 -3
@@ -199,6 +199,7 @@
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 @@
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 @@
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",
@@ -316,6 +318,16 @@
316 318 "serde_json",
317 319 ]
318 320
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 +
319 331 [[package]]
320 332 name = "bitflags"
321 333 version = "1.3.2"
@@ -379,6 +391,17 @@
379 391 "alloc-stdlib",
380 392 ]
381 393
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 +
382 405 [[package]]
383 406 name = "bumpalo"
384 407 version = "3.19.1"
@@ -2038,7 +2061,7 @@
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",
@@ -2188,6 +2211,16 @@
2188 2211 "icu_properties",
2189 2212 ]
2190 2213
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 +
2191 2224 [[package]]
2192 2225 name = "indexmap"
2193 2226 version = "1.9.3"
@@ -2480,6 +2513,12 @@
2480 2513 "vcpkg",
2481 2514 ]
2482 2515
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 +
2483 2522 [[package]]
2484 2523 name = "linux-keyutils"
2485 2524 version = "0.2.5"
@@ -2517,6 +2556,23 @@
2517 2556 source = "registry+https://github.com/rust-lang/crates.io-index"
2518 2557 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
2519 2558
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 +
2520 2576 [[package]]
2521 2577 name = "mac"
2522 2578 version = "0.1.1"
@@ -2626,6 +2682,25 @@
2626 2682 "digest",
2627 2683 ]
2628 2684
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 +
2629 2704 [[package]]
2630 2705 name = "memchr"
2631 2706 version = "2.8.0"
@@ -3181,6 +3256,15 @@
3181 3256 "thiserror 2.0.18",
3182 3257 ]
3183 3258
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 +
3184 3268 [[package]]
3185 3269 name = "pango"
3186 3270 version = "0.18.3"
@@ -3535,6 +3619,22 @@
3535 3619 "miniz_oxide",
3536 3620 ]
3537 3621
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 +
3538 3638 [[package]]
3539 3639 name = "poly1305"
3540 3640 version = "0.8.0"
@@ -3558,6 +3658,15 @@
3558 3658 "universal-hash",
3559 3659 ]
3560 3660
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 +
3561 3670 [[package]]
3562 3671 name = "portable-atomic"
3563 3672 version = "1.13.1"
@@ -3594,6 +3703,18 @@
3594 3703 source = "registry+https://github.com/rust-lang/crates.io-index"
3595 3704 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
3596 3705
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 +
3597 3718 [[package]]
3598 3719 name = "proc-macro-crate"
3599 3720 version = "1.3.1"
@@ -3662,6 +3783,13 @@
3662 3783 "unicode-ident",
3663 3784 ]
3664 3785
3786 + [[package]]
3787 + name = "pter"
3788 + version = "0.1.0"
3789 + dependencies = [
3790 + "scraper",
3791 + ]
3792 +
3665 3793 [[package]]
3666 3794 name = "pter"
3667 3795 version = "0.1.0"
@@ -4742,6 +4870,16 @@
4742 4870 "version_check",
4743 4871 ]
4744 4872
4873 + [[package]]
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 +
4745 4883 [[package]]
4746 4884 name = "socket2"
4747 4885 version = "0.6.2"
@@ -5105,6 +5243,18 @@
5105 5243 source = "registry+https://github.com/rust-lang/crates.io-index"
5106 5244 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
5107 5245
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 +
5108 5258 [[package]]
5109 5259 name = "swift-rs"
5110 5260 version = "1.0.7"
@@ -5813,7 +5963,7 @@
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 ]
@@ -6104,6 +6254,12 @@
6104 6254 source = "registry+https://github.com/rust-lang/crates.io-index"
6105 6255 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
6106 6256
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 +
6107 6263 [[package]]
6108 6264 name = "typeid"
6109 6265 version = "1.0.3"
@@ -6617,6 +6773,12 @@
6617 6773 "windows-core 0.61.2",
6618 6774 ]
6619 6775
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 +
6620 6782 [[package]]
6621 6783 name = "whoami"
6622 6784 version = "1.6.1"
M Cargo.toml +5
@@ -5,6 +5,7 @@
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 @@
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"
@@ -20,6 +20,10 @@
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 = [] }
@@ -128,6 +128,7 @@
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
@@ -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) {
@@ -226,6 +234,22 @@
226 234 }
227 235 }
228 236
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 +
229 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.
@@ -99,6 +99,9 @@
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 @@
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 @@
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 @@
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::*;
@@ -1,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 }
@@ -1,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 + }
@@ -1,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 + }