feat(export): parse notes Markdown into a block model for PDF/Word (T8.4, FR-NOTE-4)

Fills in the ExportFormat::Pdf/Docx arms that previously errored
"lands in Phase 8". markdown_to_blocks() covers headings, paragraphs,
bullet/task lists, and bold runs — everything MarkdownNotes::to_markdown
and the notes editor's toolbar actually produce, not general Markdown.
Shared by both new renderer submodules (pdf, docx).
This commit is contained in:
iamdoubz
2026-07-02 11:02:35 -05:00
parent 3dec299c4c
commit f6a5b9583a
+181 -3
View File
@@ -4,6 +4,7 @@
//! summary). Names are resolved here from the mapping; segments keep internal IDs.
use crate::models::{SpeakerInfo, TranscriptSegment};
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
@@ -111,13 +112,117 @@ impl NotesRenderer for MarkdownNotes {
ExportFormat::Bundle => Err(NotesError::Export(
"bundle export is assembled in commands::export_meeting, not NotesRenderer".into(),
)),
ExportFormat::Pdf | ExportFormat::Docx => Err(NotesError::Export(
"PDF/Word export lands in Phase 8".into(),
)),
ExportFormat::Pdf => {
let blocks = markdown_to_blocks(markdown);
pdf::render(&blocks, dest)?;
Ok(dest.to_path_buf())
}
ExportFormat::Docx => {
let blocks = markdown_to_blocks(markdown);
docx::render(&blocks, dest)?;
Ok(dest.to_path_buf())
}
}
}
}
/// One inline run of text — `bold` is only ever set on the run pulldown-cmark
/// wraps in `Strong`, which in practice means just the leading speaker-name
/// prefix ("**Alice:**") our own `to_markdown` emits, not general inline
/// formatting anywhere in a paragraph.
#[derive(Debug, Clone)]
struct Span {
text: String,
bold: bool,
}
#[derive(Debug, Clone)]
enum Block {
Heading(u8, Vec<Span>),
Paragraph(Vec<Span>),
BulletItem(Vec<Span>),
}
/// Parses notes Markdown (headings, paragraphs, bullet/task lists, bold
/// runs) into a small block model shared by the PDF and DOCX renderers —
/// good enough for what `MarkdownNotes::to_markdown` and the notes editor's
/// toolbar (bold/bullet/checkbox) actually produce, not general Markdown.
fn markdown_to_blocks(markdown: &str) -> Vec<Block> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(markdown, options);
let mut blocks = Vec::new();
let mut current: Vec<Span> = Vec::new();
let mut bold_depth = 0u32;
let mut heading_level: Option<u8> = None;
let mut in_item = false;
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
heading_level = Some(heading_level_to_u8(level));
current.clear();
}
Event::End(TagEnd::Heading(level)) => {
blocks.push(Block::Heading(
heading_level.take().unwrap_or(heading_level_to_u8(level)),
std::mem::take(&mut current),
));
}
Event::Start(Tag::Paragraph) => current.clear(),
Event::End(TagEnd::Paragraph) => {
let spans = std::mem::take(&mut current);
if in_item {
blocks.push(Block::BulletItem(spans));
} else {
blocks.push(Block::Paragraph(spans));
}
}
Event::Start(Tag::Item) => {
in_item = true;
current.clear();
}
Event::End(TagEnd::Item) => {
if !current.is_empty() {
blocks.push(Block::BulletItem(std::mem::take(&mut current)));
}
in_item = false;
}
Event::Start(Tag::Strong) => bold_depth += 1,
Event::End(TagEnd::Strong) => bold_depth = bold_depth.saturating_sub(1),
Event::Text(text) => current.push(Span {
text: text.to_string(),
bold: bold_depth > 0,
}),
Event::TaskListMarker(checked) => current.push(Span {
text: (if checked { "[x] " } else { "[ ] " }).to_string(),
bold: false,
}),
Event::SoftBreak | Event::HardBreak => current.push(Span {
text: " ".to_string(),
bold: bold_depth > 0,
}),
_ => {}
}
}
blocks
}
fn heading_level_to_u8(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
mod docx;
mod pdf;
#[cfg(test)]
mod tests {
use super::*;
@@ -169,4 +274,77 @@ mod tests {
let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone."));
assert_eq!(md, "## Summary\nDone.\n\n---\n\n**S1:** Real text.");
}
#[test]
fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() {
let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing";
let blocks = markdown_to_blocks(md);
match &blocks[0] {
Block::Heading(level, spans) => {
assert_eq!(*level, 2);
assert_eq!(spans[0].text, "Summary");
}
other => panic!("expected Heading, got {other:?}"),
}
match &blocks[1] {
Block::Paragraph(spans) => {
assert!(spans[0].bold, "speaker name prefix should be bold");
assert_eq!(spans[0].text, "Alice:");
assert!(!spans[1].bold);
}
other => panic!("expected Paragraph, got {other:?}"),
}
match &blocks[2] {
Block::BulletItem(spans) => assert_eq!(spans[0].text, "[ ] "),
other => panic!("expected BulletItem, got {other:?}"),
}
match &blocks[3] {
Block::BulletItem(spans) => assert_eq!(spans[0].text, "[x] "),
other => panic!("expected BulletItem, got {other:?}"),
}
}
// A real speaker-tagged notes sample, long enough to force a page break
// in the PDF renderer, exercising more than a one-line happy path.
fn sample_markdown() -> String {
let mut md = String::from("## Summary\n\nThis meeting covered quarterly planning.\n\n");
for i in 0..40 {
md.push_str(&format!(
"**Alice:** This is talking point number {i} about the roadmap and staffing.\n\n"
));
}
md.push_str("- [ ] Follow up with finance\n- [x] Send recap email\n");
md
}
#[test]
fn export_pdf_writes_a_valid_pdf_file() {
let dir = std::env::temp_dir().join(format!("wa-export-test-{}", uuid::Uuid::new_v4()));
let dest = dir.join("notes.pdf");
MarkdownNotes
.export(&sample_markdown(), &dest, ExportFormat::Pdf)
.expect("pdf export should succeed");
let bytes = std::fs::read(&dest).expect("pdf file should exist");
assert!(bytes.starts_with(b"%PDF-"), "missing PDF magic bytes");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn export_docx_writes_a_valid_zip_container() {
let dir = std::env::temp_dir().join(format!("wa-export-test-{}", uuid::Uuid::new_v4()));
let dest = dir.join("notes.docx");
MarkdownNotes
.export(&sample_markdown(), &dest, ExportFormat::Docx)
.expect("docx export should succeed");
let bytes = std::fs::read(&dest).expect("docx file should exist");
// .docx is a zip container — "PK\x03\x04" is the local-file-header magic.
assert!(
bytes.starts_with(b"PK\x03\x04"),
"missing zip/docx magic bytes"
);
std::fs::remove_dir_all(&dir).ok();
}
}