Mini Excel
1. Introduction
Dans le cours précédent, on a construit un parseur CSV et un parseur XML. On a vu comment transformer des données d'un format à l'autre. Il est temps de passer à la vitesse supérieure : construire une mini application tableur.
L'idée : on charge un CSV, on l'affiche, on peut le filtrer, trier, faire des calculs sur les colonnes, et même générer des documents personnalisés par publipostage (mail merge). Le tout en OCaml pur, avec les monades et les ADT qu'on connaît.
ocaml-excel ou table existent, mais construire la vôtre vous fait tout comprendre.
Les pré-requis : cours 2 (ADT, listes, map/filter/fold), cours 3 (monades, notamment la monade liste), cours 4 (parseur CSV).
2. Modèle de données
Une feuille de calcul, c'est une grille : des lignes et des colonnes. Chaque cellule peut contenir une valeur (texte, nombre) ou une formule.
---------- Modèle de données ---------- // Type d'une cellule *) type cell = | Text of string // Texte brut *) | Num of float // Nombre *) | Empty // Cellule vide *) // Une ligne est une liste de cellules *) type row = cell list // Une table : des en-têtes (noms de colonnes) et des lignes *) type table = { headers: string list; rows: row list; }
Pour charger un CSV dans notre table, on utilise le parseur du cours 4 et on convertit les chaînes en cellules :
---------- Chargement CSV ---------- // Convertir une chaîne en cellule (nombre si possible, sinon texte) *) let parse_cell s = try Num (float_of_string s) with Failure _ -> if s = "" then Empty else Text s // Charger un CSV dans une table *) let load_csv data = match run csv_parser data with | None -> None | Some (raw, _) -> Some { headers = raw.headers; rows = List.map (fun r -> List.map parse_cell r) raw.rows; }
On a aussi besoin d'afficher la table proprement :
---------- Affichage ---------- let show_cell c = match c with | Empty -> "" | Text s -> s | Num n -> string_of_float n let show_row r = String.concat " | " (List.map show_cell r) let show_table t = String.concat "\n" ((String.concat " | " t.headers) :: List.map show_row t.rows)
4. Calculs
Le cœur d'un tableur, ce sont les calculs. On peut calculer des statistiques sur une colonne, ou évaluer des formules.
4.1 Statistiques
---------- Fonctions statistiques ---------- // Extraire les valeurs numériques d'une colonne *) let nums t name = column t name |> List.filter_map (fun c -> match c with | Num n -> Some n | _ -> None) // Somme d'une colonne numérique *) let sum t name = List.fold_left (+.) 0. (nums t name) // Moyenne d'une colonne numérique *) let avg t name = let vals = nums t name in if vals = [] then 0. else List.fold_left (+.) 0. vals /. float_of_int (List.length vals) // Minimum, Maximum *) let min t name = match nums t name with | [] -> None | lst -> Some (List.fold_left min_float max_float lst) let max t name = match nums t name with | [] -> None | lst -> Some (List.fold_left max_float min_float lst) // Compter les cellules non vides d'une colonne *) let count t name = List.length (column t name)
sum, avg, min, max, count : les cinq doigts de la main statistique. Avec ça, vous pouvez analyser n'importe quel tableau de données, que ce soit des ventes, des températures, ou le nombre de tasses de café bues par vos collègues.
4.2 Formules
Allons plus loin : des formules permettant de calculer une nouvelle colonne à partir des existantes.
---------- Formules ---------- // Type d'une formule *) type formula = | Ref of string // référence à une colonne : "Nom" *) | ConstF of float // constante numérique *) | Add of formula * formula | Sub of formula * formula | Mul of formula * formula | Div of formula * formula // Évaluer une formule pour une ligne donnée *) let rec eval_formula t row_idx f = match f with | ConstF n -> Num n | Ref name -> (match cell_at t row_idx name with | None -> Empty | Some c -> c) | Add (a, b) -> (match (eval_formula t row_idx a, eval_formula t row_idx b) with | (Num x, Num y) -> Num (x +. y) | _ -> Empty) | Sub (a, b) -> ((* similaire *)) | Mul (a, b) -> ((* similaire *)) | Div (a, b) -> ((* similaire, avec division par zéro → Empty *)) // Ajouter une colonne calculée à la table *) let add_column t name f = let new_col = List.mapi (fun i _ -> eval_formula t i f) t.rows in let add_cell row cell = row @ [cell] in { headers = t.headers @ [name]; rows = List.map2 add_cell t.rows new_col; }
Exemple d'utilisation :
// Ajouter une colonne "TTC" = "Prix" * 1.20 *) let table_ttc = add_column table "TTC" (Mul (Ref "Prix", ConstF 1.20))
"=A1*B2") et parsées. Notre ADT formula est l'AST de ces formules. On pourrait ajouter un parseur de formules (en utilisant Parsec, cours 4) pour passer de "=Prix * 1.20" à notre AST.
5. Filtres & tris
Un tableur permet de filtrer les lignes selon des critères et de les trier. Avec la monade Liste et List.sort, c'est immédiat :
---------- Filtres ---------- // Type d'une condition de filtre *) type condition = | Eq of string * string // colonne = valeur *) | Gt of string * string // colonne > valeur *) | Lt of string * string // colonne < valeur *) | And of condition * condition | Or of condition * condition // Évaluer une condition sur une ligne *) let rec eval_cond t row cond = match cond with | Eq (col, val_) -> (match col_index t col with | None -> false | Some i -> show_cell (List.nth row i) = val_) | Gt (col, val_) -> (match col_index t col with | None -> false | Some i -> (match List.nth row i with | Num n -> n >. float_of_string val_ | _ -> false)) | And (a, b) -> eval_cond t row a && eval_cond t row b | Or (a, b) -> eval_cond t row a || eval_cond t row b // Filtrer la table *) let filter_rows t cond = { t with rows = List.filter (eval_cond t) t.rows } ---------- Tris ---------- // Trier la table selon une colonne *) let sort_by t col asc = match col_index t col with | None -> t | Some i -> let cmp r1 r2 = compare (show_cell (List.nth r1 i)) (show_cell (List.nth r2 i)) in let sorted = List.sort cmp t.rows in { t with rows = if asc then sorted else List.rev sorted }
Avec la monade Option et List.nth_opt, on peut sécuriser tout ça. Mais l'essentiel est là.
List.sort, au moins, on est sûr que le tri est fait. Pas comme mon étagère du bas.
6. Publipostage (Mail Merge)
Le publipostage (mail merge) permet de générer des documents personnalisés à partir d'un modèle et d'une table de données. Chaque ligne de la table remplit les "trous" du modèle pour produire un document unique.
C'est ce qui permet d'envoyer 10 000 lettres personnalisées sans les écrire à la main. Les politiques adorent. Les entreprises aussi.
---------- Publipostage ---------- // Type d'un modèle : texte avec des trous {{colonne}} *) type template = | Lit of string // Texte littéral *) | Hole of string // Trou : nom de colonne *) | Cat of template * template // Concaténation *) // Parser un modèle "Bonjour {{Nom}}, vous avez {{Âge}} ans" *) let parse_template s = let rec parse i = if i >= String.length s then [] else if s.[i] = '{' && i + 1 < String.length s && s.[i+1] = '{' then let close = String.index_from s (i+2) '}' (* en pratique, gérer l'erreur *) in let name = String.sub s (i+2) (close - i - 2) in Hole name :: parse (close + 2) else let end_lit = try String.index_from s i '{' with Not_found -> String.length s in let text = String.sub s i (end_lit - i) in Lit text :: parse end_lit in let parts = parse 0 in List.fold_right (fun a b -> Cat (a, b)) parts (Lit "") // Remplir un modèle avec une ligne de la table *) let rec fill_template t row tmpl = match tmpl with | Lit s -> s | Hole col -> (match col_index t col with | None -> "{{" ^ col ^ "}}" | Some i -> show_cell (List.nth row i)) | Cat (a, b) -> fill_template t row a ^ fill_template t row b // Générer tous les documents (un par ligne) *) let mail_merge t tmpl = List.map (fill_template t) t.rows
Exemple :
let modele = parse_template "Bonjour {{Nom}}, vous êtes de {{Ville}} !" // Avec notre table du cours 4 : *) // | Nom | Âge | Ville | *) // | Alice | 30 | Paris | *) // | Bob | 25 | Lyon | *) mail_merge table modele (* = ["Bonjour Alice, vous êtes de Paris !"; *) (* "Bonjour Bob, vous êtes de Lyon !"] *)
7. Export & rapports
Une table, c'est bien. Mais une table qu'on peut exporter en HTML, c'est mieux. On peut générer des rapports, des tableaux de bord, des pages web.
---------- Export HTML ---------- let escape_html s = s |> Str.global_replace (Str.regexp "&") "&" |> Str.global_replace (Str.regexp "<") "<" |> Str.global_replace (Str.regexp ">") ">" let export_html t title = Printf.sprintf {|<!DOCTYPE html> <html><head><meta charset="UTF-8"> <title>%s</title> <style>table { border-collapse: collapse; width: 100%%; } th, td { border: 1px solid #ccc; padding: 6px; text-align: left; } th { background: #0E8A7A; color: #fff; } tr:nth-child(even) { background: #f5f5f5; } </style></head><body> <h1>%s</h1> <table> <thead><tr>%s</tr></thead> <tbody>%s</tbody> </table></body></html>|} title title (String.concat "" (List.map (fun h -> Printf.sprintf "<th>%s</th>" (escape_html h)) t.headers)) (String.concat "" (List.map (fun row -> Printf.sprintf "<tr>%s</tr>" (String.concat "" (List.map (fun c -> Printf.sprintf "<td>%s</td>" (escape_html (show_cell c))) row))) t.rows))
Et on peut aussi exporter en CSV après transformation, pour sauvegarder ou échanger :
---------- Export CSV ---------- let escape_csv s = if String.contains s ',' || String.contains s '"' then "\"" ^ Str.global_replace (Str.regexp "\"") "\"" s ^ "\"" else s let export_csv t = String.concat "\n" ((String.concat "," (List.map escape_csv t.headers)) :: List.map (fun r -> String.concat "," (List.map (escape_csv % show_cell) r)) t.rows)
|> Filter |> Add column |> Sort |> Export HTML. C'est un programme ETL (Extract, Transform, Load) complet en moins de 200 lignes d'OCaml.
8. Conclusion
Ce cours a montré comment combiner toutes les techniques des cours précédents pour construire une mini application tableur :
| Brique | Source | Usage dans Mini Excel |
|---|---|---|
| ADT | Cours 2 | Type cell, table, formula, condition |
| Map / Filter / Fold | Cours 2 | List.map sur les lignes/colonnes, filter des lignes |
| Monade Option | Cours 3 | safe_cell avec Option.bind |
| Monade Liste | Cours 3 | Requêtes "SQL-like" sur les lignes |
| Parseur CSV | Cours 4 | Chargement des données load_csv |
| Génération HTML | Cours 4 | export_html, rapport |
Ce qu'il faut retenir
- Un tableur, c'est une table + des formules + des filtres + de l'export.
- Les formules sont des ADT (AST) qu'on évalue avec une fonction récursive.
- Le publipostage sépare le modèle des données — le principe MVC avant l'heure.
- Tout ça tient en ~200 lignes d'OCaml pur, grâce aux combinateurs des cours précédents.
Dans le prochain cours, on utilisera le parseur HTML pour construire une bibliothèque de manipulation de documents à la jQuery : sélecteurs CSS, traversée du DOM, modifications, génération de pages.
Fait avec ❤️, des SUM et des VLOOKUP artisanaux