diff --git a/crates/markdown/src/lib.rs b/crates/markdown/src/lib.rs index 1cbb63e..a2dba2b 100644 --- a/crates/markdown/src/lib.rs +++ b/crates/markdown/src/lib.rs @@ -194,6 +194,8 @@ pub enum PdfNode { Blockquote { content: String }, /// Footnote with id and content Footnote { id: String, content: String }, + /// Image with source path and alt text + Image { src: String, alt: String }, } /// Result of rendering Markdown for PDF export @@ -660,6 +662,94 @@ mod tests { assert!(matches!(items[1], PdfNode::Paragraph { ref content } if content == "Two")); } + #[test] + fn test_render_for_pdf_standalone_image() { + let engine = MarkdownEngine::new(); + let markdown = "![Alt text](.writer-assets/abc123.png)"; + let result = engine.render_for_pdf(markdown, MarkdownProfile::GfmSafe).unwrap(); + + let image = result.nodes.iter().find_map(|node| match node { + PdfNode::Image { src, alt } => Some((src, alt)), + _ => None, + }); + + assert!(image.is_some(), "expected PdfNode::Image in nodes"); + let (src, alt) = image.unwrap(); + assert_eq!(src, ".writer-assets/abc123.png"); + assert_eq!(alt, "Alt text"); + } + + #[test] + fn test_render_for_pdf_image_with_empty_alt() { + let engine = MarkdownEngine::new(); + let markdown = "![](.writer-assets/abc123.jpg)"; + let result = engine.render_for_pdf(markdown, MarkdownProfile::GfmSafe).unwrap(); + + let image = result.nodes.iter().find_map(|node| match node { + PdfNode::Image { src, alt } => Some((src, alt)), + _ => None, + }); + + assert!(image.is_some(), "expected PdfNode::Image in nodes"); + let (src, alt) = image.unwrap(); + assert_eq!(src, ".writer-assets/abc123.jpg"); + assert_eq!(alt, ""); + } + + #[test] + fn test_render_for_pdf_image_between_paragraphs() { + let engine = MarkdownEngine::new(); + let markdown = "Before text.\n\n![A photo](.writer-assets/photo.png)\n\nAfter text."; + let result = engine.render_for_pdf(markdown, MarkdownProfile::GfmSafe).unwrap(); + + let node_types: Vec<_> = result + .nodes + .iter() + .map(|n| match n { + PdfNode::Paragraph { .. } => "paragraph", + PdfNode::Image { .. } => "image", + _ => "other", + }) + .collect(); + + assert_eq!(node_types, vec!["paragraph", "image", "paragraph"]); + } + + #[test] + fn test_render_for_pdf_image_inline_with_text() { + let engine = MarkdownEngine::new(); + let markdown = "Text before ![inline](.writer-assets/x.png) text after."; + let result = engine.render_for_pdf(markdown, MarkdownProfile::GfmSafe).unwrap(); + + assert!( + result + .nodes + .iter() + .any(|n| matches!(n, PdfNode::Image { src, .. } if src == ".writer-assets/x.png")) + ); + assert!( + result + .nodes + .iter() + .any(|n| matches!(n, PdfNode::Paragraph { content } if content.contains("Text before"))) + ); + assert!( + result + .nodes + .iter() + .any(|n| matches!(n, PdfNode::Paragraph { content } if content.contains("text after."))) + ); + } + + #[test] + fn test_pdf_image_serializes_with_correct_type_tag() { + let node = PdfNode::Image { src: ".writer-assets/img.png".to_string(), alt: "My image".to_string() }; + let json = serde_json::to_value(&node).unwrap(); + assert_eq!(json["type"], "image"); + assert_eq!(json["src"], ".writer-assets/img.png"); + assert_eq!(json["alt"], "My image"); + } + #[test] fn test_strikethrough() { let engine = MarkdownEngine::new(); diff --git a/crates/markdown/src/transformer.rs b/crates/markdown/src/transformer.rs index a858be7..b98a29d 100644 --- a/crates/markdown/src/transformer.rs +++ b/crates/markdown/src/transformer.rs @@ -26,6 +26,14 @@ impl MarkdownTransformer { text.push_str(&link_text); } } + NodeValue::Image(link) => { + let alt = Self::extract_text_content(child); + if !alt.is_empty() { + text.push_str(&alt); + } else { + text.push_str(&link.url); + } + } NodeValue::Strikethrough => text.push_str(&Self::extract_text_content(child)), NodeValue::FootnoteReference(_) => continue, NodeValue::TaskItem(task_item) => { @@ -54,9 +62,36 @@ impl MarkdownTransformer { nodes.push(PdfNode::Heading { level: heading.level, content }); } NodeValue::Paragraph => { - let content = Self::extract_text_content(child); - if !content.is_empty() { - nodes.push(PdfNode::Paragraph { content }); + let has_images = child + .children() + .any(|c| matches!(&c.data.borrow().value, NodeValue::Image(_))); + if has_images { + let mut text_parts: Vec = Vec::new(); + for inline_child in child.children() { + match &inline_child.data.borrow().value { + NodeValue::Image(link) => { + let pending = text_parts.join("").trim().to_string(); + if !pending.is_empty() { + nodes.push(PdfNode::Paragraph { content: pending }); + text_parts.clear(); + } + let alt = Self::extract_text_content(inline_child); + nodes.push(PdfNode::Image { src: link.url.clone(), alt }); + } + NodeValue::Text(t) => text_parts.push(t.to_string()), + NodeValue::SoftBreak | NodeValue::LineBreak => text_parts.push(" ".to_string()), + _ => text_parts.push(Self::extract_text_content(inline_child)), + } + } + let pending = text_parts.join("").trim().to_string(); + if !pending.is_empty() { + nodes.push(PdfNode::Paragraph { content: pending }); + } + } else { + let content = Self::extract_text_content(child); + if !content.is_empty() { + nodes.push(PdfNode::Paragraph { content }); + } } } NodeValue::CodeBlock(code_block) => { diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md index 04436d1..4238fdc 100644 --- a/docs/tasks/image-handling.md +++ b/docs/tasks/image-handling.md @@ -104,11 +104,11 @@ updated: 2026-03-21 ### Backend (Rust) -- [ ] Add `Image` variant to `PdfNode` enum in `crates/markdown/src/lib.rs` +- [x] Add `Image` variant to `PdfNode` enum in `crates/markdown/src/lib.rs` - Fields: `src: String`, `alt: String` -- [ ] Update `transform_to_pdf_nodes()` in `crates/markdown/src/transformer.rs` +- [x] Update `transform_to_pdf_nodes()` in `crates/markdown/src/transformer.rs` - Handle Comrak image nodes → emit `PdfNode::Image` -- [ ] Update `PdfRenderResult` serialization to include new variant +- [x] Update `PdfRenderResult` serialization to include new variant ### Frontend