feat(export): PDF renderer via printpdf + built-in Helvetica (T8.4, FR-NOTE-4)

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.
This commit is contained in:
iamdoubz
2026-07-02 11:02:41 -05:00
parent f6a5b9583a
commit ec7ae5c01f
+173
View File
@@ -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, &regular, &bold, 0.0, "");
y -= 3.0;
}
Block::BulletItem(spans) => {
render_runs(
&doc,
&mut layer,
&mut y,
spans,
&regular,
&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<String> {
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: std::fmt::Display>(e: E) -> NotesError {
NotesError::Export(format!("pdf font: {e}"))
}