pubmed_formatter/pmc/markdown/
mod.rs

1//! Markdown conversion functionality for PMC articles
2//!
3//! This module provides functionality to convert parsed PMC articles into
4//! well-formatted Markdown documents with configurable styling options.
5
6mod config;
7mod entities;
8mod frontmatter;
9mod heading;
10mod metadata;
11mod references;
12mod sections;
13mod toc;
14
15use std::collections::HashMap;
16
17use pubmed_parser::pmc::PmcArticle;
18
19pub use config::{FigureOptions, HeadingStyle, MarkdownConfig, MetadataOptions, ReferenceStyle};
20use entities::clean_content;
21use heading::format_heading;
22
23/// PMC to Markdown converter
24pub struct PmcMarkdownConverter {
25    config: MarkdownConfig,
26}
27
28impl PmcMarkdownConverter {
29    /// Create a new converter with default configuration
30    pub fn new() -> Self {
31        Self {
32            config: MarkdownConfig::default(),
33        }
34    }
35
36    /// Create a converter with custom configuration
37    pub fn with_config(config: MarkdownConfig) -> Self {
38        Self { config }
39    }
40
41    /// Set whether to include metadata
42    pub fn with_include_metadata(mut self, include: bool) -> Self {
43        self.config.metadata.include_metadata = include;
44        self
45    }
46
47    /// Set whether to include table of contents
48    pub fn with_include_toc(mut self, include: bool) -> Self {
49        self.config.include_toc = include;
50        self
51    }
52
53    /// Set heading style
54    pub fn with_heading_style(mut self, style: HeadingStyle) -> Self {
55        self.config.heading_style = style;
56        self
57    }
58
59    /// Set reference style
60    pub fn with_reference_style(mut self, style: ReferenceStyle) -> Self {
61        self.config.reference_style = style;
62        self
63    }
64
65    /// Set maximum heading level
66    pub fn with_max_heading_level(mut self, level: u8) -> Self {
67        self.config.max_heading_level = level.clamp(1, 6);
68        self
69    }
70
71    /// Set whether to include ORCID links
72    pub fn with_include_orcid_links(mut self, include: bool) -> Self {
73        self.config.metadata.include_orcid_links = include;
74        self
75    }
76
77    /// Set whether to include identifier links
78    pub fn with_include_identifier_links(mut self, include: bool) -> Self {
79        self.config.metadata.include_identifier_links = include;
80        self
81    }
82
83    /// Set whether to include figure captions
84    pub fn with_include_figure_captions(mut self, include: bool) -> Self {
85        self.config.figures.include_figure_captions = include;
86        self
87    }
88
89    /// Set whether to use YAML frontmatter for metadata
90    pub fn with_yaml_frontmatter(mut self, use_yaml: bool) -> Self {
91        self.config.metadata.use_yaml_frontmatter = use_yaml;
92        self
93    }
94
95    /// Convert a PMC article to Markdown with optional figure paths
96    pub fn convert_with_figures(
97        &self,
98        article: &PmcArticle,
99        figure_paths: Option<&HashMap<String, String>>,
100    ) -> String {
101        let mut markdown = String::new();
102
103        if self.config.metadata.include_metadata {
104            markdown.push_str(&metadata::convert_metadata(&self.config, article));
105            markdown.push_str("\n\n");
106        } else {
107            let title = article.title().unwrap_or("Untitled");
108            markdown.push_str(&format_heading(&self.config, &clean_content(title), 1));
109            markdown.push_str("\n\n");
110        }
111
112        if self.config.include_toc {
113            markdown.push_str(&toc::convert_toc(&self.config, article));
114            markdown.push_str("\n\n");
115        }
116
117        markdown.push_str(&sections::convert_sections(
118            &self.config,
119            article.sections(),
120            1,
121            figure_paths,
122        ));
123
124        if !article.references().is_empty() {
125            markdown.push_str(&references::convert_references(
126                &self.config,
127                article.references(),
128            ));
129        }
130
131        markdown.push_str(&sections::convert_additional_sections(
132            &self.config,
133            article,
134        ));
135
136        markdown.trim().to_string()
137    }
138
139    /// Convert a PMC article to Markdown
140    pub fn convert(&self, article: &PmcArticle) -> String {
141        self.convert_with_figures(article, None)
142    }
143}
144
145impl Default for PmcMarkdownConverter {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use pubmed_parser::common::{Author, PmcId, PubMedId, PublicationDate};
155    use pubmed_parser::pmc::{ArticleMeta, Front, JournalMeta, TitleGroup};
156
157    fn test_article(title: &str, pmcid: &str) -> PmcArticle {
158        PmcArticle {
159            article_type: None,
160            front: Front {
161                journal_meta: JournalMeta {
162                    title: Some("Test Journal".to_string()),
163                    abbreviation: None,
164                    issn_print: None,
165                    issn_electronic: None,
166                    publisher: None,
167                },
168                article_meta: ArticleMeta {
169                    pmcid: PmcId::parse(pmcid).unwrap(),
170                    pmid: None,
171                    doi: None,
172                    categories: vec![],
173                    title_group: TitleGroup {
174                        article_title: Some(title.to_string()),
175                        subtitle: None,
176                    },
177                    authors: vec![],
178                    pub_dates: vec![],
179                    volume: None,
180                    issue: None,
181                    fpage: None,
182                    lpage: None,
183                    elocation_id: None,
184                    history: vec![],
185                    permissions: None,
186                    abstracts: vec![],
187                    keywords: vec![],
188                    keyword_groups: vec![],
189                    subject_groups: vec![],
190                    related_articles: vec![],
191                    author_notes: vec![],
192                    funding: vec![],
193                },
194            },
195            body: None,
196            back: None,
197            supplementary_materials: vec![],
198            data_availability: None,
199        }
200    }
201
202    #[test]
203    fn test_markdown_converter_creation() {
204        let converter = PmcMarkdownConverter::new();
205        assert!(converter.config.metadata.include_metadata);
206        assert_eq!(converter.config.heading_style, HeadingStyle::ATX);
207        assert_eq!(converter.config.reference_style, ReferenceStyle::Numbered);
208    }
209
210    #[test]
211    fn test_configuration_builder() {
212        let converter = PmcMarkdownConverter::new()
213            .with_include_metadata(false)
214            .with_heading_style(HeadingStyle::Setext)
215            .with_reference_style(ReferenceStyle::AuthorYear)
216            .with_max_heading_level(4);
217
218        assert!(!converter.config.metadata.include_metadata);
219        assert_eq!(converter.config.heading_style, HeadingStyle::Setext);
220        assert_eq!(converter.config.reference_style, ReferenceStyle::AuthorYear);
221        assert_eq!(converter.config.max_heading_level, 4);
222    }
223
224    #[test]
225    fn test_heading_formatting() {
226        let config = MarkdownConfig::default();
227
228        assert_eq!(format_heading(&config, "Title", 1), "# Title");
229        assert_eq!(format_heading(&config, "Subtitle", 2), "## Subtitle");
230
231        let setext_config = MarkdownConfig {
232            heading_style: HeadingStyle::Setext,
233            ..Default::default()
234        };
235        assert_eq!(format_heading(&setext_config, "Title", 1), "Title\n=====");
236        assert_eq!(
237            format_heading(&setext_config, "Subtitle", 2),
238            "Subtitle\n--------"
239        );
240        assert_eq!(format_heading(&setext_config, "Section", 3), "### Section");
241    }
242
243    #[test]
244    fn test_clean_content() {
245        let dirty = "<p>This is <em>emphasis</em> and &amp; entities</p>";
246        let clean = clean_content(dirty);
247        assert_eq!(clean, "This is emphasis and & entities");
248    }
249
250    #[test]
251    fn test_anchor_creation() {
252        use heading::heading_anchor;
253
254        assert_eq!(heading_anchor("Introduction"), "introduction");
255        assert_eq!(heading_anchor("Methods & Results"), "methods-results");
256        assert_eq!(heading_anchor("Discussion (2023)"), "discussion-2023");
257    }
258
259    #[test]
260    fn test_basic_conversion() {
261        let converter = PmcMarkdownConverter::new();
262
263        let mut article = test_article("Test Article", "PMC1234567");
264        article.front.article_meta.pmid = Some(PubMedId::parse("12345").unwrap());
265        article.front.article_meta.authors = vec![Author::from_full_name("John Doe".to_string())];
266        article.front.article_meta.pub_dates = vec![PublicationDate {
267            pub_type: None,
268            year: Some(2023),
269            month: None,
270            day: None,
271        }];
272        article.front.article_meta.doi = Some("10.1000/test".to_string());
273        article.article_type = Some("research-article".to_string());
274        article.front.article_meta.keywords = vec!["test".to_string(), "example".to_string()];
275
276        let markdown = converter.convert(&article);
277        assert!(markdown.contains("# Test Article"));
278        assert!(markdown.contains("**Authors:** John Doe"));
279        assert!(markdown.contains("**Journal:** Test Journal"));
280        assert!(markdown.contains("DOI: 10.1000/test"));
281        assert!(markdown.contains("**Keywords:** test, example"));
282    }
283
284    #[test]
285    fn test_yaml_frontmatter_basic() {
286        let converter = PmcMarkdownConverter::new().with_yaml_frontmatter(true);
287
288        let mut article = test_article("Test Article", "PMC1234567");
289        article.front.article_meta.pmid = Some(PubMedId::parse("12345").unwrap());
290        article.front.article_meta.authors = vec![
291            Author::from_full_name("John Doe".to_string()),
292            Author::from_full_name("Jane Smith".to_string()),
293        ];
294        article.front.article_meta.pub_dates = vec![PublicationDate {
295            pub_type: None,
296            year: Some(2023),
297            month: Some(5),
298            day: Some(15),
299        }];
300        article.front.article_meta.doi = Some("10.1000/test".to_string());
301        article.article_type = Some("research-article".to_string());
302        article.front.article_meta.keywords = vec!["test".to_string(), "example".to_string()];
303
304        let markdown = converter.convert(&article);
305
306        assert!(markdown.starts_with("---\n"));
307        let delimiter_count = markdown.matches("---").count();
308        assert_eq!(
309            delimiter_count, 2,
310            "Should have opening and closing YAML frontmatter delimiters"
311        );
312
313        assert!(markdown.contains("title: Test Article"));
314        assert!(markdown.contains("authors:"));
315        assert!(markdown.contains("- John Doe"));
316        assert!(markdown.contains("- Jane Smith"));
317        assert!(markdown.contains("journal: Test Journal"));
318        assert!(
319            markdown.contains("pub_date: '2023-05-15'")
320                || markdown.contains("pub_date: 2023-05-15")
321        );
322        assert!(markdown.contains("pmcid: PMC1234567"));
323        assert!(markdown.contains("pmid: '12345'"));
324        assert!(markdown.contains("doi: 10.1000/test"));
325        assert!(markdown.contains("article_type: research-article"));
326        assert!(markdown.contains("keywords:"));
327        assert!(markdown.contains("- test"));
328        assert!(markdown.contains("- example"));
329    }
330
331    #[test]
332    fn test_yaml_frontmatter_with_special_characters() {
333        let converter = PmcMarkdownConverter::new().with_yaml_frontmatter(true);
334
335        let mut article = test_article("COVID-19: A Comprehensive Study", "PMC7890123");
336        article.front.journal_meta.title = Some("Nature: Medicine & Science".to_string());
337        article.front.article_meta.authors =
338            vec![Author::from_full_name("O'Brien, Michael".to_string())];
339        article.front.article_meta.pub_dates = vec![PublicationDate {
340            pub_type: None,
341            year: Some(2023),
342            month: None,
343            day: None,
344        }];
345        article.front.article_meta.doi = Some("10.1038/s41591-023-01234-5".to_string());
346        article.article_type = Some("research-article".to_string());
347        article.front.article_meta.keywords = vec![
348            "#COVID-19".to_string(),
349            "SARS-CoV-2".to_string(),
350            "vaccine".to_string(),
351        ];
352
353        let markdown = converter.convert(&article);
354
355        assert!(
356            markdown.contains("title: 'COVID-19: A Comprehensive Study'")
357                || markdown.contains("title: \"COVID-19: A Comprehensive Study\"")
358        );
359        assert!(
360            markdown.contains("journal: 'Nature: Medicine & Science'")
361                || markdown.contains("journal: \"Nature: Medicine & Science\"")
362        );
363        assert!(markdown.contains("'#COVID-19'") || markdown.contains("\"#COVID-19\""));
364        assert!(markdown.contains("SARS-CoV-2"));
365    }
366
367    #[test]
368    fn test_yaml_frontmatter_backward_compatibility() {
369        let converter = PmcMarkdownConverter::new();
370        assert!(!converter.config.metadata.use_yaml_frontmatter);
371
372        let article = test_article("Test Article", "PMC1234567");
373
374        let markdown = converter.convert(&article);
375
376        assert!(markdown.contains("# Test Article"));
377        assert!(markdown.contains("**Journal:** Test Journal"));
378        assert!(!markdown.starts_with("---\n"));
379    }
380
381    #[test]
382    fn test_builder_pattern_with_yaml_frontmatter() {
383        let converter = PmcMarkdownConverter::new()
384            .with_yaml_frontmatter(true)
385            .with_include_metadata(true)
386            .with_heading_style(HeadingStyle::ATX);
387
388        assert!(converter.config.metadata.use_yaml_frontmatter);
389        assert!(converter.config.metadata.include_metadata);
390        assert_eq!(converter.config.heading_style, HeadingStyle::ATX);
391    }
392}