feat(correspondance): le format de lettre commerciale devient un gabarit réutilisable
Fixé avec le dossier KissMetrics du 24/08 et validé par l'opérateur : « on peut se souvenir de ce format pour les communications avec KM et futurs prospects/clients ». Il ne servira à rien s'il faut le reconstituer à chaque fois — d'où un gabarit, un script, et les raisons écrites. LE TEST DU GABARIT : la lettre du 24/08 s'en régénère à l'identique, au mot près, vérifié par diff du texte extrait. Un gabarit qui ne reproduit pas son propre exemple n'est pas un gabarit. LES CHOIX, ET POURQUOI. Charter pour le texte — dessinée par Matthew Carter pour tenir le petit corps là où d'autres se délitent — et Optima pour les titres. Les deux incorporées au PDF : le rendu est le même chez le destinataire. Chiffres elzéviriens, pour que les montants s'alignent au lieu de faire des bâtons. Deux encadrés, deux usages : le gris porte une question, le rouge sourd ce qui doit être lu en diagonale — UN SEUL par lettre, deux et plus rien ne ressort. Les tableaux portent un <thead>, sans quoi ils perdent leur en-tête en se coupant entre deux pages. LE PIÈGE, consigné parce qu'il coûte une heure à qui le redécouvre : weasyprint NE SAIT PAS rendre Apple Color Emoji. C'est un format bitmap sbix qu'il ignore, et les trois emblèmes 🏹💻🪽 sortent en carrés vides — sans erreur, sans avertissement. D'où correspondance/emblemes/*.png, rendus une fois pour toutes par test/emoji2png.ts, qui passe par Chromium : lui lit la police système. Les PNG sont incorporés en base64 À LA GÉNÉRATION, pas dans le gabarit : un gabarit de 400 Ko dont 97 % de charabia ne se relit pas. Le PDF produit reste autonome. Les trois emblèmes viennent du site — « Gabriel 🪽 Radureau, pour 🏹 réussir vos projets 💻 ». L'arc et l'ange sont dans le nom lui-même. lettre.py proteste au-delà de deux pages : une lettre d'affaires qui déborde ne se lit pas, et la tentation est alors de rétrécir la typographie plutôt que le propos. C'est le propos qu'il faut resserrer. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Produit une lettre commerciale Arcodange en PDF, depuis le gabarit maison.
|
||||
|
||||
POURQUOI CE SCRIPT EXISTE. Deux choses ne se font pas à la main sans se tromper.
|
||||
|
||||
1. `weasyprint` NE SAIT PAS rendre Apple Color Emoji : c'est un format bitmap
|
||||
`sbix` qu'il ignore, et les trois emblèmes 🏹💻🪽 sortent en carrés vides.
|
||||
Il faut les incorporer en images. Elles sont ici en PNG, rendues une fois
|
||||
pour toutes par `test/emoji2png.ts` (Chromium, lui, lit la police système).
|
||||
2. Un gabarit qui porterait ces images en base64 pèserait 400 Ko dont 97 % de
|
||||
charabia. On les garde en fichiers et on les incorpore À LA GÉNÉRATION,
|
||||
pour que le gabarit reste relisible et que le PDF reste autonome.
|
||||
|
||||
Usage :
|
||||
python3 correspondance/lettre.py corps.html sortie.pdf \
|
||||
--titre "Contracts and invoices — cycles M1 to M4" \
|
||||
--date "24 August 2026" --prenom Evan \
|
||||
--destinataire "Evan Sforzo" --fonction "Chief Executive Officer" \
|
||||
--societe "Kissmetrics Inc." \
|
||||
--adresse "2850 34th Street North, 307 — St. Petersburg, Florida 33713 — United States" \
|
||||
--copie "Hendrik Rootering" \
|
||||
--pied "Arcodange × Kissmetrics Inc. — 24 August 2026"
|
||||
|
||||
`corps.html` ne contient que le corps : les <h2>, <p>, <table>, encadrés. L'en-tête,
|
||||
le bloc destinataire, la signature et le pied de page viennent du gabarit.
|
||||
Sans `--copie`, la ligne « cc » disparaît.
|
||||
"""
|
||||
import argparse, base64, pathlib, re, subprocess, sys
|
||||
|
||||
ICI = pathlib.Path(__file__).parent
|
||||
GABARIT = ICI / "lettre.template.html"
|
||||
EMBLEMES = {"ARC": "arc.png", "LAPTOP": "laptop.png", "AILE": "aile.png"}
|
||||
|
||||
|
||||
def data_uri(chemin: pathlib.Path) -> str:
|
||||
return "data:image/png;base64," + base64.b64encode(chemin.read_bytes()).decode()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
a = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
a.add_argument("corps"); a.add_argument("sortie")
|
||||
for champ in ("titre", "date", "prenom", "destinataire", "fonction",
|
||||
"societe", "adresse", "pied"):
|
||||
a.add_argument(f"--{champ}", required=True)
|
||||
a.add_argument("--copie", default="")
|
||||
a.add_argument("--garder-html", action="store_true",
|
||||
help="conserve le HTML intermédiaire à côté du PDF")
|
||||
o = a.parse_args()
|
||||
|
||||
html = GABARIT.read_text(encoding="utf-8")
|
||||
|
||||
for cle, fichier in EMBLEMES.items():
|
||||
p = ICI / "emblemes" / fichier
|
||||
if not p.exists():
|
||||
print(f"emblème manquant : {p}\n"
|
||||
f" le régénérer : cd test && deno run -A emoji2png.ts <dossier> 🏹 💻 🪽",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
html = html.replace("{{" + cle + "}}", data_uri(p))
|
||||
|
||||
for cle, val in (("TITRE", o.titre), ("DATE", o.date), ("PRENOM", o.prenom),
|
||||
("DESTINATAIRE", o.destinataire), ("FONCTION", o.fonction),
|
||||
("SOCIETE", o.societe), ("ADRESSE", o.adresse),
|
||||
("COPIE", o.copie), ("PIED", o.pied)):
|
||||
html = html.replace("{{" + cle + "}}", val)
|
||||
|
||||
# Pas de destinataire en copie : on retire la ligne entière, pas seulement
|
||||
# son contenu, sinon il reste un « cc » orphelin.
|
||||
if not o.copie:
|
||||
html = re.sub(r'<br>\s*<span class="a"[^>]*>cc</span>\s* \s*', "", html)
|
||||
|
||||
# Le corps remplace tout ce qui sépare le sous-titre de la signature.
|
||||
corps = pathlib.Path(o.corps).read_text(encoding="utf-8")
|
||||
deb = html.index("<h1>")
|
||||
fin = html.index('<div class="signature">')
|
||||
entete = html[deb:html.index("<p>Dear ")]
|
||||
html = html[:deb] + entete + f"<p>Dear {o.prenom},</p>\n\n" + corps + "\n\n" + html[fin:]
|
||||
|
||||
tmp = pathlib.Path(o.sortie).with_suffix(".html")
|
||||
tmp.write_text(html, encoding="utf-8")
|
||||
r = subprocess.run(["weasyprint", str(tmp), o.sortie])
|
||||
if r.returncode == 0 and not o.garder_html:
|
||||
tmp.unlink()
|
||||
if r.returncode == 0:
|
||||
pages = subprocess.run(["pdfinfo", o.sortie], capture_output=True, text=True).stdout
|
||||
n = next((l.split()[1] for l in pages.splitlines() if l.startswith("Pages")), "?")
|
||||
print(f"ok — {o.sortie} ({n} page(s))")
|
||||
if n not in ("1", "2"):
|
||||
print(" ATTENTION : au-delà de deux pages, une lettre d'affaires se lit mal. "
|
||||
"Resserrer le corps plutôt que la typographie.", file=sys.stderr)
|
||||
return r.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user