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:
+181
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user