pubmed_formatter/pmc/markdown/
mod.rs1mod 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
23pub struct PmcMarkdownConverter {
25 config: MarkdownConfig,
26}
27
28impl PmcMarkdownConverter {
29 pub fn new() -> Self {
31 Self {
32 config: MarkdownConfig::default(),
33 }
34 }
35
36 pub fn with_config(config: MarkdownConfig) -> Self {
38 Self { config }
39 }
40
41 pub fn with_include_metadata(mut self, include: bool) -> Self {
43 self.config.metadata.include_metadata = include;
44 self
45 }
46
47 pub fn with_include_toc(mut self, include: bool) -> Self {
49 self.config.include_toc = include;
50 self
51 }
52
53 pub fn with_heading_style(mut self, style: HeadingStyle) -> Self {
55 self.config.heading_style = style;
56 self
57 }
58
59 pub fn with_reference_style(mut self, style: ReferenceStyle) -> Self {
61 self.config.reference_style = style;
62 self
63 }
64
65 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 pub fn with_include_orcid_links(mut self, include: bool) -> Self {
73 self.config.metadata.include_orcid_links = include;
74 self
75 }
76
77 pub fn with_include_identifier_links(mut self, include: bool) -> Self {
79 self.config.metadata.include_identifier_links = include;
80 self
81 }
82
83 pub fn with_include_figure_captions(mut self, include: bool) -> Self {
85 self.config.figures.include_figure_captions = include;
86 self
87 }
88
89 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 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(§ions::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(§ions::convert_additional_sections(
132 &self.config,
133 article,
134 ));
135
136 markdown.trim().to_string()
137 }
138
139 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 & 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}