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.
460 lines
14 KiB
Rust
460 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 §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<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(
|
|
§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<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(),
|
|
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());
|
|
}
|
|
}
|