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:
@@ -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<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}"))
|
||||
}
|
||||
Reference in New Issue
Block a user