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
Commit: 995f47ec668f27127ddd19c839cb4dc13b474ee8
Parent: 287b892
14 files changed, +882 insertions, -3 deletions
M Cargo.lock +165 -3
@@ -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"
M Cargo.toml +5
@@ -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