#!/usr/bin/env python3 # /// script # requires-python = ">=3.9" # dependencies = ["fonttools[woff]>=4.50"] # /// """Prépare les polices du CV, dans assets/polices : des instances statiques de Mona Sans et de Monaspace Neon, renommées « adalan … », en .ttf pour la trame PowerPoint et en .woff2 pour la page HTML et le PDF. uv run scripts/preparer_polices.py MONA_SANS MONASPACE_NEON_400 MONASPACE_NEON_500 [MONA_SANS_LATIN_EXT] MONA_SANS Mona Sans variable (axes wdth et wght), en .ttf ou .woff2 MONASPACE_NEON_400 Monaspace Neon Regular, en .otf, .ttf ou .woff2 MONASPACE_NEON_500 Monaspace Neon Medium MONA_SANS_LATIN_EXT facultatif : le jeu latin étendu de Mona Sans, quand MONA_SANS n'est qu'un jeu latin Dans le dépôt adalan-skills, les fichiers du site suffisent (après npm install dans www/) : uv run scripts/preparer_polices.py \\ ../../www/node_modules/@fontsource-variable/mona-sans/files/mona-sans-latin-wdth-normal.woff2 \\ ../../www/node_modules/@fontsource/monaspace-neon/files/monaspace-neon-latin-400-normal.woff2 \\ ../../www/node_modules/@fontsource/monaspace-neon/files/monaspace-neon-latin-500-normal.woff2 \\ ../../www/node_modules/@fontsource-variable/mona-sans/files/mona-sans-latin-ext-wdth-normal.woff2 À ne relancer que pour changer une police ; scripts/dessiner_trame.py embarque ensuite les .ttf dans la trame, et scripts/cv_web.py les .woff2 dans chaque page HTML. Pourquoi « adalan » plutôt que « Mona Sans » : les deux polices sont sous licence OFL avec des noms réservés (« Mona », « Monaspace », « Neon »). Une instance statique ou un sous-ensemble est une version modifiée, qui ne peut pas porter ces noms. Le dessin, le copyright et la licence restent ceux d'origine. Ce que le script fait à chaque police : - fige Mona Sans aux graisses et chasses de la charte (texte 420 et 650, chapeau 340, titres 800 à 112 %, titres de carte 700 à 106 %) ; - convertit Monaspace Neon en contours TrueType, que PowerPoint sait embarquer ; - ajoute le petit carré en diagonale des sur-titres et des puces (■, U+25A0) et l'espace fine insécable (U+202F) de la typographie française ; - renomme la famille, garde le copyright et la licence, et note la modification. """ import sys from pathlib import Path try: from fontTools.merge import Merger, Options from fontTools.pens.cu2quPen import Cu2QuPen from fontTools.pens.ttGlyphPen import TTGlyphPen from fontTools.ttLib import TTFont, newTable from fontTools.varLib.instancer import instantiateVariableFont except ImportError: sys.exit("fontTools manque : lancer le script avec « uv run », ou installer le paquet (pip install 'fonttools[woff]').") SORTIE = Path(__file__).resolve().parent.parent / "assets" / "polices" MODIFICATION = "Modified by adalan in 2026 (static instance, renamed, glyphs added) for the adalan CV template." # Fichier, famille, style dans la famille (Regular ou Bold), axes. MONA = [ ("adalan-sans.ttf", "adalan Sans", "Regular", {"wdth": 100, "wght": 420}), ("adalan-sans-bold.ttf", "adalan Sans", "Bold", {"wdth": 100, "wght": 650}), ("adalan-sans-light.ttf", "adalan Sans Light", "Regular", {"wdth": 100, "wght": 340}), ("adalan-display.ttf", "adalan Display", "Regular", {"wdth": 112, "wght": 800}), ("adalan-heading.ttf", "adalan Heading", "Regular", {"wdth": 106, "wght": 700}), ] NEON = [ ("adalan-mono.ttf", "adalan Mono", 400), ("adalan-mono-medium.ttf", "adalan Mono Medium", 500), ] LARGEUR_OS2 = {100: 5, 106: 5, 112: 6} # usWidthClass : normale, semi-étendue def ouvrir(chemin): if not Path(chemin).is_file(): sys.exit(f"Police introuvable : {chemin}") f = TTFont(chemin) f.flavor = None return f def recharger(font): """Un TTFont propre, comme lu depuis un fichier, après des modifications de tables.""" from io import BytesIO b = BytesIO() font.save(b) b.seek(0) return TTFont(b) def nommer(font, famille, style, poids): name = font["name"] copyright_ = name.getDebugName(0) or name.getDebugName(7) or "" licence = name.getDebugName(13) or "SIL Open Font License 1.1" garder = {0: f"{copyright_} {MODIFICATION}".strip(), 13: licence, 14: "https://openfontlicense.org"} complet = famille if style == "Regular" else f"{famille} {style}" ps = complet.replace(" ", "").replace("adalan", "adalan-", 1) garder.update({1: famille, 2: style, 3: f"adalan;{ps}", 4: complet, 5: "Version 1.000", 6: ps}) name.names = [] for nid, valeur in garder.items(): name.setName(valeur, nid, 3, 1, 0x409) name.setName(valeur, nid, 1, 0, 0) os2 = font["OS/2"] os2.usWeightClass = poids gras = style == "Bold" # fsSelection : bit 0 italique, bit 5 gras, bit 6 regular, bit 7 USE_TYPO_METRICS gardé os2.fsSelection = (os2.fsSelection & 0b10000000) | (0b100000 if gras else 0b1000000) font["head"].macStyle = 1 if gras else 0 if "STAT" in font: del font["STAT"] def ajouter_carre(font): """Ajoute ■ (U+25A0) : le carré des sur-titres, deux coins arrondis en diagonale (haut gauche, bas droite).""" upm = font["head"].unitsPerEm cmap = font.getBestCmap() largeur = font["hmtx"][cmap[ord("H")]][0] hauteur_capitales = getattr(font["OS/2"], "sCapHeight", 0) or int(upm * 0.7) cote = int(upm * 0.5) x0 = (largeur - cote) // 2 if largeur > cote else 0 y0 = (hauteur_capitales - cote) // 2 x1, y1 = x0 + cote, y0 + cote grand, petit = int(cote * 0.5), int(cote * 0.1) pen = TTGlyphPen(None) # sens horaire (contour extérieur TrueType), depuis le bas gauche pen.moveTo((x0 + petit, y0)) pen.qCurveTo((x0, y0), (x0, y0 + petit)) pen.lineTo((x0, y1 - grand)) pen.qCurveTo((x0, y1), (x0 + grand, y1)) pen.lineTo((x1 - petit, y1)) pen.qCurveTo((x1, y1), (x1, y1 - petit)) pen.lineTo((x1, y0 + grand)) pen.qCurveTo((x1, y0), (x1 - grand, y0)) pen.closePath() ajouter_glyphe(font, "uni25A0", pen.glyph(), max(largeur, cote), 0x25A0) def ajouter_espace_fine(font): """Fait de U+202F (espace fine insécable) une espace de 0,16 em, si la police ne l'a pas.""" if 0x202F in font.getBestCmap(): return pen = TTGlyphPen(None) ajouter_glyphe(font, "uni202F", pen.glyph(), int(font["head"].unitsPerEm * 0.16), 0x202F) def ajouter_glyphe(font, nom, glyphe, avance, code): ordre = font.getGlyphOrder() if nom in ordre: return font.setGlyphOrder(ordre + [nom]) font["glyf"][nom] = glyphe glyphe.recalcBounds(font["glyf"]) font["hmtx"][nom] = (avance, getattr(glyphe, "xMin", 0)) for table in font["cmap"].tables: if table.isUnicode(): table.cmap[code] = nom font["maxp"].numGlyphs = len(font.getGlyphOrder()) if "post" in font and font["post"].formatType == 2: font["post"].extraNames = [] def cff_vers_truetype(font): """Convertit des contours cubiques (CFF) en contours quadratiques (glyf).""" jeu = font.getGlyphSet() glyf = newTable("glyf") glyf.glyphOrder = font.getGlyphOrder() glyf.glyphs = {} for nom in font.getGlyphOrder(): pen = TTGlyphPen(jeu) jeu[nom].draw(Cu2QuPen(pen, max_err=1.0, reverse_direction=True)) glyf.glyphs[nom] = pen.glyph() del font["CFF "] font["glyf"] = glyf font["loca"] = newTable("loca") font["maxp"].tableVersion = 0x00010000 for attr in ("maxZones", "maxTwilightPoints", "maxStorage", "maxFunctionDefs", "maxInstructionDefs", "maxStackElements", "maxSizeOfInstructions", "maxComponentElements", "maxPoints", "maxContours", "maxCompositePoints", "maxCompositeContours", "maxComponentDepth"): setattr(font["maxp"], attr, 0) font["maxp"].maxZones = 1 font["head"].indexToLocFormat = 0 font["head"].glyphDataFormat = 0 font["post"].formatType = 2.0 font["post"].extraNames = [] font["post"].mapping = {} font.sfntVersion = "\x00\x01\x00\x00" for nom, g in glyf.glyphs.items(): g.recalcBounds(glyf) return recharger(font) def woff2(ttf): """La même police en WOFF2, compressée pour le web.""" f = TTFont(ttf, recalcTimestamp=False) f.flavor = "woff2" f.save(ttf.with_suffix(".woff2")) def mona(source, latin_ext=None): for fichier, famille, style, axes in MONA: f = ouvrir(source) instantiateVariableFont(f, axes, inplace=True) if latin_ext: e = ouvrir(latin_ext) instantiateVariableFont(e, axes, inplace=True) f = fusionner(f, e) nommer(f, famille, style, axes["wght"]) f["OS/2"].usWidthClass = LARGEUR_OS2[axes["wdth"]] ajouter_espace_fine(f) f = recharger(f) f.save(SORTIE / fichier) woff2(SORTIE / fichier) print(f"{fichier} : {famille} {style}, chasse {axes['wdth']} %, graisse {axes['wght']}") def fusionner(latin, latin_ext): """Réunit le jeu latin et le jeu latin étendu d'une même instance.""" dossier = SORTIE / ".fusion" dossier.mkdir(exist_ok=True) a, b = dossier / "latin.ttf", dossier / "latin-ext.ttf" latin.save(a) latin_ext.save(b) try: fusion = Merger(options=Options(drop_tables=["STAT", "HVAR", "MVAR"])).merge([str(a), str(b)]) finally: a.unlink() b.unlink() dossier.rmdir() # la fusion recopie les noms du premier fichier ; le cmap couvre les deux jeux return recharger(fusion) def neon(source, fichier, famille, poids): f = ouvrir(source) if "CFF " in f: f = cff_vers_truetype(f) for t in ("GSUB", "GPOS", "GDEF"): # ligatures de code et « texture healing » : inutiles dans un CV if t in f: del f[t] nommer(f, famille, "Regular", poids) ajouter_carre(f) ajouter_espace_fine(f) f = recharger(f) f.save(SORTIE / fichier) woff2(SORTIE / fichier) print(f"{fichier} : {famille}, graisse {poids}") def main(): if len(sys.argv) not in (4, 5): sys.exit(__doc__) SORTIE.mkdir(parents=True, exist_ok=True) mona(sys.argv[1], sys.argv[4] if len(sys.argv) == 5 else None) neon(sys.argv[2], *NEON[0]) neon(sys.argv[3], *NEON[1]) if __name__ == "__main__": main()