//! Export service for generating Markdown and PDF from a synthesis. //! //! Provides functions to convert a synthesis (with its sections and items) //! into downloadable Markdown or PDF format. use printpdf::*; use std::io::BufWriter; use crate::errors::AppError; use crate::models::synthesis::NewsSection; /// Generate a Markdown representation of the synthesis. /// /// Output format: /// ```text /// # Synthese de la Semaine 2026-W12 /// /// Generee le 2026-03-21 /// /// ## Section Title /// /// ### Article Title /// /// [Lien](https://example.com/article) /// /// Summary text here. /// ``` pub fn generate_markdown(week: &str, date: &str, sections: &[NewsSection]) -> String { let mut md = String::new(); md.push_str(&format!("# Synthese de la Semaine {}\n\n", week)); md.push_str(&format!("Generee le {}\n", date)); for section in sections { md.push_str(&format!("\n## {}\n", section.title)); for item in §ion.items { md.push_str(&format!("\n### {}\n\n", item.title)); md.push_str(&format!("[Lien]({})\n\n", item.url)); md.push_str(&format!("{}\n", item.summary)); } } md } /// Page dimensions (A4 in mm). const PAGE_WIDTH_MM: f32 = 210.0; const PAGE_HEIGHT_MM: f32 = 297.0; /// Margins in mm. const MARGIN_LEFT: f32 = 25.0; const MARGIN_TOP: f32 = 25.0; const MARGIN_RIGHT: f32 = 25.0; const MARGIN_BOTTOM: f32 = 25.0; /// Maximum text width in mm. const TEXT_WIDTH: f32 = PAGE_WIDTH_MM - MARGIN_LEFT - MARGIN_RIGHT; /// Font sizes in pt. const TITLE_SIZE: f32 = 18.0; const DATE_SIZE: f32 = 10.0; const SECTION_SIZE: f32 = 14.0; const ITEM_TITLE_SIZE: f32 = 11.0; const URL_SIZE: f32 = 8.0; const BODY_SIZE: f32 = 10.0; const FOOTER_SIZE: f32 = 8.0; /// Approximate width of a character in mm for a given font size. /// This is a rough estimate for Helvetica (average char width ~0.5 * size in pt). /// 1 pt = 0.3528 mm. fn approx_char_width_mm(font_size: f32) -> f32 { font_size * 0.3528 * 0.5 } /// Wrap text into lines that fit within the given width in mm. fn wrap_text(text: &str, font_size: f32, max_width_mm: f32) -> Vec { let char_width = approx_char_width_mm(font_size); let max_chars = (max_width_mm / char_width).floor() as usize; if max_chars == 0 { return vec![text.to_string()]; } let mut lines = Vec::new(); for paragraph in text.split('\n') { if paragraph.is_empty() { lines.push(String::new()); continue; } let words: Vec<&str> = paragraph.split_whitespace().collect(); if words.is_empty() { lines.push(String::new()); continue; } let mut current_line = String::new(); for word in words { if current_line.is_empty() { current_line = word.to_string(); } else if current_line.len() + 1 + word.len() <= max_chars { current_line.push(' '); current_line.push_str(word); } else { lines.push(current_line); current_line = word.to_string(); } } if !current_line.is_empty() { lines.push(current_line); } } lines } /// PDF writer state: tracks current Y position and manages page creation. struct PdfWriter { doc: PdfDocumentReference, current_page: PdfPageIndex, current_layer: PdfLayerIndex, y_pos: f32, font_regular: IndirectFontRef, font_bold: IndirectFontRef, font_italic: IndirectFontRef, } impl PdfWriter { fn new( doc: PdfDocumentReference, page: PdfPageIndex, layer: PdfLayerIndex, font_regular: IndirectFontRef, font_bold: IndirectFontRef, font_italic: IndirectFontRef, ) -> Self { Self { doc, current_page: page, current_layer: layer, y_pos: PAGE_HEIGHT_MM - MARGIN_TOP, font_regular, font_bold, font_italic, } } /// Ensure there is enough vertical space; if not, add a new page. fn ensure_space(&mut self, needed_mm: f32) { if self.y_pos - needed_mm < MARGIN_BOTTOM { let (page, layer) = self.doc.add_page( Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), "Page", ); self.current_page = page; self.current_layer = layer; self.y_pos = PAGE_HEIGHT_MM - MARGIN_TOP; } } /// Write a single line of text at the current position. fn write_line(&mut self, text: &str, font: &IndirectFontRef, size: f32, color: (f32, f32, f32)) { let line_height_mm = size * 0.3528 * 1.4; self.ensure_space(line_height_mm); let layer = self.doc.get_page(self.current_page).get_layer(self.current_layer); layer.set_fill_color(Color::Rgb(Rgb::new(color.0, color.1, color.2, None))); layer.use_text(text, size, Mm(MARGIN_LEFT), Mm(self.y_pos), font); self.y_pos -= line_height_mm; } /// Write wrapped text (may span multiple lines). fn write_wrapped( &mut self, text: &str, font: &IndirectFontRef, size: f32, color: (f32, f32, f32), ) { let lines = wrap_text(text, size, TEXT_WIDTH); for line in &lines { self.write_line(line, font, size, color); } } /// Add vertical spacing in mm. fn space(&mut self, mm: f32) { self.y_pos -= mm; } } /// Indigo color (R, G, B) normalized to 0.0-1.0 for PDF. const COLOR_INDIGO: (f32, f32, f32) = (0.263, 0.275, 0.898); // #4338CA const COLOR_DARK: (f32, f32, f32) = (0.133, 0.133, 0.133); // #222222 const COLOR_GRAY: (f32, f32, f32) = (0.4, 0.4, 0.4); // #666666 const COLOR_LIGHT_GRAY: (f32, f32, f32) = (0.6, 0.6, 0.6); // #999999 /// Generate a PDF document from the synthesis, returned as raw bytes. /// /// Uses the `printpdf` crate with built-in PDF fonts (Helvetica family). /// No external font files are required. /// /// The PDF contains: /// - A title with "AI Weekly Synth - Semaine {week}" /// - The generation date /// - Section headers and items with titles, URLs, and summaries /// - Automatic page breaks when content exceeds page height pub fn generate_pdf( week: &str, date: &str, sections: &[NewsSection], ) -> Result, AppError> { let (doc, page, layer) = PdfDocument::new( format!("AI Weekly Synth - Semaine {}", week), Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), "Page 1", ); let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica).map_err(|e| { AppError::Internal(anyhow::anyhow!("Failed to load Helvetica font: {}", e)) })?; let font_bold = doc .add_builtin_font(BuiltinFont::HelveticaBold) .map_err(|e| { AppError::Internal(anyhow::anyhow!("Failed to load Helvetica-Bold font: {}", e)) })?; let font_italic = doc .add_builtin_font(BuiltinFont::HelveticaOblique) .map_err(|e| { AppError::Internal(anyhow::anyhow!( "Failed to load Helvetica-Oblique font: {}", e )) })?; let mut writer = PdfWriter::new(doc, page, layer, font_regular, font_bold, font_italic); // Title let title = format!("AI Weekly Synth - Semaine {}", week); writer.write_line(&title, &writer.font_bold.clone(), TITLE_SIZE, COLOR_INDIGO); writer.space(3.0); // Date let date_line = format!("Generee le {}", date); writer.write_line( &date_line, &writer.font_italic.clone(), DATE_SIZE, COLOR_GRAY, ); writer.space(8.0); // Sections for section in sections { // Section title writer.write_line( §ion.title, &writer.font_bold.clone(), SECTION_SIZE, COLOR_INDIGO, ); writer.space(3.0); for item in §ion.items { // Item title (bold) writer.write_wrapped( &item.title, &writer.font_bold.clone(), ITEM_TITLE_SIZE, COLOR_DARK, ); // URL (italic, gray) writer.write_wrapped( &item.url, &writer.font_italic.clone(), URL_SIZE, COLOR_LIGHT_GRAY, ); writer.space(1.0); // Summary writer.write_wrapped( &item.summary, &writer.font_regular.clone(), BODY_SIZE, COLOR_DARK, ); writer.space(4.0); } writer.space(4.0); } // Footer writer.space(8.0); writer.write_line( "Genere par AI Weekly Synth", &writer.font_italic.clone(), FOOTER_SIZE, COLOR_LIGHT_GRAY, ); // Render to bytes let buf = Vec::new(); let mut buf_writer = BufWriter::new(buf); writer .doc .save(&mut buf_writer) .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to render PDF: {}", e)))?; let bytes = buf_writer .into_inner() .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to flush PDF buffer: {}", e)))?; Ok(bytes) } #[cfg(test)] mod tests { use super::*; use crate::models::synthesis::{NewsItem, NewsSection}; fn sample_sections() -> Vec { vec![ NewsSection { title: "Annonces Majeures".into(), items: vec![ NewsItem { title: "OpenAI lance GPT-5".into(), url: "https://openai.com/gpt5".into(), summary: "OpenAI a annonce la sortie de GPT-5 avec des capacites ameliorees." .into(), date: None, }, NewsItem { title: "Google DeepMind publie Gemini 3".into(), url: "https://deepmind.google/gemini3".into(), summary: "DeepMind presente Gemini 3, son nouveau modele multimodal.".into(), date: None, }, ], }, NewsSection { title: "Recherche".into(), items: vec![NewsItem { title: "Nouveau papier sur le RLHF".into(), url: "https://arxiv.org/abs/2026.12345".into(), summary: "Une nouvelle approche du RLHF prometteuse.".into(), date: None, }], }, ] } // --- Markdown tests --- #[test] fn markdown_contains_title() { let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections()); assert!(md.starts_with("# Synthese de la Semaine 2026-W12\n")); } #[test] fn markdown_contains_date() { let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections()); assert!(md.contains("Generee le 2026-03-21")); } #[test] fn markdown_contains_section_titles() { let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections()); assert!(md.contains("## Annonces Majeures")); assert!(md.contains("## Recherche")); } #[test] fn markdown_contains_item_titles_and_links() { let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections()); assert!(md.contains("### OpenAI lance GPT-5")); assert!(md.contains("[Lien](https://openai.com/gpt5)")); assert!(md.contains("### Nouveau papier sur le RLHF")); assert!(md.contains("[Lien](https://arxiv.org/abs/2026.12345)")); } #[test] fn markdown_contains_summaries() { let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections()); assert!(md.contains("OpenAI a annonce la sortie de GPT-5")); assert!(md.contains("Une nouvelle approche du RLHF prometteuse.")); } #[test] fn markdown_empty_sections_produces_header_only() { let md = generate_markdown("2026-W12", "2026-03-21", &[]); assert!(md.contains("# Synthese de la Semaine 2026-W12")); assert!(md.contains("Generee le 2026-03-21")); // No section headers assert!(!md.contains("##")); } // --- PDF tests --- #[test] fn pdf_output_is_nonempty_and_starts_with_pdf_header() { let bytes = generate_pdf("2026-W12", "2026-03-21", &sample_sections()).unwrap(); assert!(!bytes.is_empty(), "PDF output should not be empty"); assert!( bytes.starts_with(b"%PDF"), "PDF output should start with %PDF magic bytes" ); } #[test] fn pdf_empty_sections_still_produces_valid_pdf() { let bytes = generate_pdf("2026-W12", "2026-03-21", &[]).unwrap(); assert!(!bytes.is_empty()); assert!(bytes.starts_with(b"%PDF")); } #[test] fn pdf_contains_title_text() { let bytes = generate_pdf("2026-W12", "2026-03-21", &sample_sections()).unwrap(); // The PDF binary content should contain the title string somewhere let content = String::from_utf8_lossy(&bytes); assert!( content.contains("AI Weekly Synth"), "PDF should contain the application name" ); } // --- Text wrapping tests --- #[test] fn wrap_text_short_line_fits() { let lines = wrap_text("Hello world", 10.0, 160.0); assert_eq!(lines.len(), 1); assert_eq!(lines[0], "Hello world"); } #[test] fn wrap_text_long_line_wraps() { // Very narrow width to force wrapping let lines = wrap_text("Hello world from the test", 10.0, 20.0); assert!(lines.len() > 1, "Long text should wrap into multiple lines"); } #[test] fn wrap_text_empty_input() { let lines = wrap_text("", 10.0, 160.0); assert_eq!(lines.len(), 1); assert!(lines[0].is_empty()); } }