From ec7ae5c01ff88b964b30b5eeb5c0946d2b86004c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:41 -0500 Subject: [PATCH] feat(export): PDF renderer via printpdf + built-in Helvetica (T8.4, FR-NOTE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Character-count word wrap (printpdf's builtin fonts expose no glyph metrics) and per-boldness-run line groups (a bold speaker-name prefix gets its own line above the dialogue) — simple choices that keep the renderer self-contained with no bundled font asset. --- src-tauri/src/notes/pdf.rs | 173 +++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src-tauri/src/notes/pdf.rs diff --git a/src-tauri/src/notes/pdf.rs b/src-tauri/src/notes/pdf.rs new file mode 100644 index 0000000..492b96a --- /dev/null +++ b/src-tauri/src/notes/pdf.rs @@ -0,0 +1,173 @@ +//! PDF export (Phase 8, FR-NOTE-4) via printpdf + built-in Helvetica — no +//! external font file or binary/cloud conversion, consistent with the +//! fully-local invariant. + +use super::{Block, NotesError, Span}; +use printpdf::{ + BuiltinFont, IndirectFontRef, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, +}; +use std::path::Path; + +const PAGE_W: f32 = 210.0; // A4, mm +const PAGE_H: f32 = 297.0; +const MARGIN: f32 = 20.0; +const BODY_SIZE: f32 = 11.0; + +pub(super) fn render(blocks: &[Block], dest: &Path) -> Result<(), NotesError> { + let (doc, page1, layer1) = PdfDocument::new("Meeting notes", Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + let regular = doc + .add_builtin_font(BuiltinFont::Helvetica) + .map_err(font_err)?; + let bold = doc + .add_builtin_font(BuiltinFont::HelveticaBold) + .map_err(font_err)?; + + let mut layer = doc.get_page(page1).get_layer(layer1); + let mut y = PAGE_H - MARGIN; + + for block in blocks { + match block { + Block::Heading(level, spans) => { + draw_wrapped( + &doc, + &mut layer, + &mut y, + &flatten(spans), + &bold, + heading_size(*level), + 0.0, + ); + y -= 2.0; + } + Block::Paragraph(spans) => { + render_runs(&doc, &mut layer, &mut y, spans, ®ular, &bold, 0.0, ""); + y -= 3.0; + } + Block::BulletItem(spans) => { + render_runs( + &doc, + &mut layer, + &mut y, + spans, + ®ular, + &bold, + 6.0, + "\u{2022} ", + ); + y -= 1.5; + } + } + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(NotesError::Io)?; + } + let file = std::fs::File::create(dest).map_err(NotesError::Io)?; + doc.save(&mut std::io::BufWriter::new(file)) + .map_err(|e| NotesError::Export(format!("pdf save: {e}")))?; + Ok(()) +} + +/// Runs of the same boldness each get their own wrapped line-group — a bold +/// speaker-name prefix ends up on its own line above the dialogue rather +/// than sharing a line with it. Simpler than tracking a shared text cursor +/// across a font change mid-line, and still reads fine. +#[allow(clippy::too_many_arguments)] +fn render_runs( + doc: &PdfDocumentReference, + layer: &mut PdfLayerReference, + y: &mut f32, + spans: &[Span], + regular: &IndirectFontRef, + bold: &IndirectFontRef, + indent: f32, + prefix: &str, +) { + let mut first = true; + for (is_bold, text) in group_by_boldness(spans) { + let text = if first && !prefix.is_empty() { + format!("{prefix}{text}") + } else { + text + }; + first = false; + let font = if is_bold { bold } else { regular }; + draw_wrapped(doc, layer, y, &text, font, BODY_SIZE, indent); + } +} + +/// ponytail: character-count word wrap — printpdf's builtin fonts don't +/// expose glyph-width metrics, so this estimates an average character width +/// instead of measuring real text width. Upgrade to measured widths if +/// wrapped lines look visibly ragged. +fn draw_wrapped( + doc: &PdfDocumentReference, + layer: &mut PdfLayerReference, + y: &mut f32, + text: &str, + font: &IndirectFontRef, + size: f32, + indent: f32, +) { + let line_h = size * 0.3528 * 1.4; + let wrap_at = (((PAGE_W - 2.0 * MARGIN - indent) / (size * 0.14)) as usize).max(10); + for line in wrap_text(text, wrap_at) { + if *y - line_h < MARGIN { + let (page, l) = doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + *layer = doc.get_page(page).get_layer(l); + *y = PAGE_H - MARGIN; + } + layer.use_text(&line, size, Mm(MARGIN + indent), Mm(*y), font); + *y -= line_h; + } +} + +fn wrap_text(text: &str, max_chars: usize) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current.push_str(word); + } else if current.len() + 1 + word.len() <= max_chars { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current.push_str(word); + } + } + if !current.is_empty() || lines.is_empty() { + lines.push(current); + } + lines +} + +fn group_by_boldness(spans: &[Span]) -> Vec<(bool, String)> { + let mut groups: Vec<(bool, String)> = Vec::new(); + for s in spans { + if let Some(last) = groups.last_mut() { + if last.0 == s.bold { + last.1.push_str(&s.text); + continue; + } + } + groups.push((s.bold, s.text.clone())); + } + groups +} + +fn flatten(spans: &[Span]) -> String { + spans.iter().map(|s| s.text.as_str()).collect() +} + +fn heading_size(level: u8) -> f32 { + match level { + 1 => 18.0, + 2 => 15.0, + _ => 13.0, + } +} + +fn font_err(e: E) -> NotesError { + NotesError::Export(format!("pdf font: {e}")) +}