You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

457 lines
14 KiB
Rust

//! 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 &section.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<String> {
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<Vec<u8>, 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(
&section.title,
&writer.font_bold.clone(),
SECTION_SIZE,
COLOR_INDIGO,
);
writer.space(3.0);
for item in &section.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<NewsSection> {
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(),
},
NewsItem {
title: "Google DeepMind publie Gemini 3".into(),
url: "https://deepmind.google/gemini3".into(),
summary:
"DeepMind presente Gemini 3, son nouveau modele multimodal.".into(),
},
],
},
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(),
}],
},
]
}
// --- 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());
}
}