#!/usr/bin/env node import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import sharp from 'sharp'; import { UMAP } from 'umap-js'; import { Matrix } from 'ml-matrix'; import cliProgress from 'cli-progress'; import chalk from 'chalk'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Configuration const PNGS_DIR = path.join(__dirname, 'output', 'pngs'); const DATA_DIR = path.join(__dirname, 'output', 'data'); const FONT_INDEX_PATH = path.join(__dirname, 'input', 'font-index.json'); const OUTPUT_FILENAME = 'typography_data.json'; const FULL_OUTPUT_PATH = path.join(DATA_DIR, OUTPUT_FILENAME); // Paramètres UMAP const UMAP_PARAMS = { nComponents: 2, nNeighbors: 15, minDist: 1.0, metric: 'euclidean', random: Math.random }; // Configuration de pondération pour l'influence catégorielle const CATEGORY_WEIGHT = 0.3; // 30% d'influence de la catégorie const PIXEL_WEIGHT = 0.7; // 70% d'influence des pixels // Configuration de fusion des familles const ENABLE_FONT_FUSION = true; // Activer la fusion des familles de polices const FUSION_PREFIX_LENGTH = 2; // Nombre de tirets pour définir le préfixe de fusion // Configuration de la barre de progression const progressBar = new cliProgress.SingleBar({ format: chalk.cyan('{bar}') + ' | {percentage}% | {value}/{total} | {fontName}', barCompleteChar: '\u2588', barIncompleteChar: '\u2591', hideCursor: true }); /** * Analyse la qualité d'un PNG pour détecter les polices problématiques */ async function analyzePNGQuality(pngPath) { try { // Charger l'image en niveaux de gris const image = sharp(pngPath); const { data, info } = await image.grayscale().raw().toBuffer({ resolveWithObject: true }); const { width, height } = info; const totalPixels = width * height; let blackPixels = 0; let whitePixels = 0; let grayPixels = 0; // Analyser chaque pixel for (let i = 0; i < data.length; i++) { const pixel = data[i]; if (pixel < 50) { blackPixels++; } else if (pixel > 200) { whitePixels++; } else { grayPixels++; } } const blackPercentage = (blackPixels / totalPixels) * 100; const whitePercentage = (whitePixels / totalPixels) * 100; const grayPercentage = (grayPixels / totalPixels) * 100; // Vérifications de qualité const hasContent = blackPixels > 0; const isMostlyWhite = whitePercentage > 90; const hasReasonableContent = 0.1 <= blackPercentage && blackPercentage <= 50; return { totalPixels, blackPixels, whitePixels, grayPixels, blackPercentage, whitePercentage, grayPercentage, hasContent, isMostlyWhite, hasReasonableContent, qualityScore: hasReasonableContent ? blackPercentage : 0 }; } catch (error) { console.error(`❌ Error during l'analyse de ${pngPath}:`, error.message); return null; } } /** * Charge un PNG et le convertit en matrice de pixels normalisée * Effectue aussi une validation de qualité */ async function loadPNGAsMatrix(pngPath) { try { // Charger l'image en niveaux de gris const image = sharp(pngPath); const { data, info } = await image.grayscale().raw().toBuffer({ resolveWithObject: true }); // Vérifier les dimensions if (info.width !== 40 || info.height !== 40) { console.warn(`⚠️ Taille inattendue pour ${pngPath}: ${info.width}x${info.height}`); // Redimensionner si nécessaire const resized = await sharp(pngPath) .resize(40, 40) .grayscale() .raw() .toBuffer({ resolveWithObject: true }); data = resized.data; } // Analyze quality const quality = await analyzePNGQuality(pngPath); if (!quality) { return { pixelVector: null, quality: null }; } // Vérifier si le PNG a un contenu raisonnable if (!quality.hasReasonableContent) { console.warn(`⚠️ PNG de mauvaise qualité: ${pngPath} (${quality.blackPercentage.toFixed(1)}% noir)`); return { pixelVector: null, quality: null }; } // Convertir en tableau et normaliser (0-255 → 0-1) const pixelMatrix = Array.from(data).map(pixel => pixel / 255.0); return { pixelVector: pixelMatrix, quality }; } catch (error) { console.error(`❌ Erreur lors du chargement de ${pngPath}:`, error.message); return { pixelVector: null, quality: null }; } } /** * Extrait le préfixe d'un nom de police pour la fusion */ function extractFusionPrefix(fontId, fontData, maxDashes = FUSION_PREFIX_LENGTH) { const parts = fontId.split('-'); if (parts.length <= 1) { return fontId; // Pas de tirets, garder le nom complet } // NOUVELLE LOGIQUE : Basée sur les subsets if (fontData && fontData.subsets && Array.isArray(fontData.subsets)) { // Chercher un subset qui correspond à une partie de l'ID for (const subset of fontData.subsets) { // Ignorer les subsets génériques if (['latin', 'latin-ext', 'cyrillic', 'cyrillic-ext', 'greek', 'greek-ext'].includes(subset)) { continue; } // Vérifier si le subset est présent dans l'ID if (fontId.includes(subset)) { // Soustraire le subset de l'ID pour obtenir le nom de base const baseName = fontId.replace(`-${subset}`, '').replace(subset, ''); if (baseName && baseName !== fontId) { console.log(chalk.yellow(` 🔍 ${fontId} → ${baseName} (subset: ${subset})`)); return baseName; } } } } // FALLBACK : Logique originale pour les cas non couverts // Cas spéciaux pour les familles connues const specialCases = { 'baloo': ['baloo-2', 'baloo-bhai-2', 'baloo-bhaijaan-2', 'baloo-bhaina-2', 'baloo-chettan-2', 'baloo-da-2', 'baloo-paaji-2', 'baloo-tamma-2', 'baloo-tammudu-2', 'baloo-thambi-2'], 'ibm-plex': ['ibm-plex'], 'playwrite': ['playwrite'] }; // Vérifier les cas spéciaux for (const [familyPrefix, patterns] of Object.entries(specialCases)) { for (const pattern of patterns) { if (fontId.startsWith(pattern)) { return familyPrefix; } } } // Cas spéciaux avec regex plus permissifs if (fontId.startsWith('noto-serif-')) { return 'noto-serif'; } if (fontId.startsWith('noto-')) { return 'noto'; } // Logique intelligente : si le 2ème mot est "sans", "serif", ou "plex", c'est une famille principale const secondWord = parts[1]; if (secondWord === 'sans' || secondWord === 'serif' || secondWord === 'plex') { return parts.slice(0, 2).join('-'); // Ex: "playwright-sans" → "playwright-sans" } // Sinon, tout ce qui vient après le premier mot sont des variantes return parts[0]; // Ex: "playwright-sk" → "playwright" } /** * Fusionne les familles de polices basées sur leur préfixe */ function mergeFontFamilies(fontDataList, pixelMatrices) { if (!ENABLE_FONT_FUSION) { return { fontDataList, pixelMatrices }; } console.log(chalk.blue('🔄 Fusion des familles de polices...')); const prefixGroups = {}; const prefixPixelGroups = {}; // Grouper les polices par préfixe for (let i = 0; i < fontDataList.length; i++) { const font = fontDataList[i]; const prefix = extractFusionPrefix(font.id, font); if (!prefixGroups[prefix]) { prefixGroups[prefix] = []; prefixPixelGroups[prefix] = []; } prefixGroups[prefix].push(font); prefixPixelGroups[prefix].push(pixelMatrices[i]); } // Fusionner les groupes avec plus d'une police const mergedFonts = []; const mergedPixels = []; let fusionCount = 0; let totalReduction = 0; for (const [prefix, fonts] of Object.entries(prefixGroups)) { if (fonts.length > 1) { // Grouper les polices const allPixels = prefixPixelGroups[prefix]; // Choisir une police représentative au lieu de calculer la moyenne let representativePixels; let representativeFont; // Logique pour choisir la police représentative if (prefix === 'noto') { // Pour Noto, privilégier noto-sans-arabic (plus représentative) ou noto-sans-latin representativeFont = fonts.find(f => f.id === 'noto-sans-arabic') || fonts.find(f => f.id === 'noto-sans-latin') || fonts.find(f => f.id === 'noto-sans') || fonts[0]; } else if (prefix === 'noto-serif') { // Pour Noto Serif, privilégier noto-serif-latin ou noto-serif representativeFont = fonts.find(f => f.id === 'noto-serif-latin') || fonts.find(f => f.id === 'noto-serif') || fonts[0]; } else if (prefix === 'ibm-plex') { // Pour IBM Plex, privilégier ibm-plex-sans representativeFont = fonts.find(f => f.id === 'ibm-plex-sans') || fonts.find(f => f.id === 'ibm-plex') || fonts[0]; } else if (prefix === 'baloo') { // Pour Baloo, privilégier baloo-2 (la version principale) representativeFont = fonts.find(f => f.id === 'baloo-2') || fonts[0]; } else { // Pour les autres familles, prendre la première ou chercher une variante "standard" representativeFont = fonts.find(f => f.id.includes('-regular') || f.id.includes('-normal')) || fonts.find(f => !f.id.includes('-italic') && !f.id.includes('-bold')) || fonts[0]; } // Trouver les pixels correspondants à la police représentative const representativeIndex = fonts.findIndex(f => f.id === representativeFont.id); representativePixels = allPixels[representativeIndex]; // Agréger les informations de poids et styles const allWeights = [...new Set(fonts.flatMap(f => f.weights || []))].sort((a, b) => a - b); const allStyles = [...new Set(fonts.flatMap(f => f.styles || []))].sort(); const allSubsets = [...new Set(fonts.flatMap(f => f.subsets || []))].sort(); // Créer la police fusionnée const mergedFont = { ...representativeFont, id: prefix, // Utiliser le préfixe comme ID name: prefix.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), // Nom formaté // Utiliser le nom de la police représentative pour les images imageName: representativeFont.id, // Nom pour les images (ex: "noto-sans-arabic") weights: allWeights, styles: allStyles, subsets: allSubsets, originalVariants: fonts.map(f => ({ id: f.id, name: f.name, google_fonts_url: f.google_fonts_url, weights: f.weights || [], styles: f.styles || [] })), variantCount: fonts.length, fusionInfo: { merged: true, originalCount: fonts.length, representative: representativeFont.id, representativeName: representativeFont.name, variants: fonts.map(f => f.id), selectionMethod: prefix === 'noto' ? 'noto-sans-latin-priority' : prefix === 'noto-serif' ? 'noto-serif-latin-priority' : prefix === 'ibm-plex' ? 'ibm-plex-sans-priority' : prefix === 'baloo' ? 'baloo-2-priority' : 'standard-variant-priority' } }; mergedFonts.push(mergedFont); mergedPixels.push(representativePixels); fusionCount++; totalReduction += fonts.length - 1; console.log(chalk.green(` ✓ ${prefix}: ${fonts.length} variantes → 1 famille`)); } else { // Police unique, pas de fusion const singleFont = { ...fonts[0], imageName: fonts[0].id // Ajouter imageName pour la cohérence }; mergedFonts.push(singleFont); mergedPixels.push(prefixPixelGroups[prefix][0]); } } console.log(chalk.green(`✅ Fusion terminée: ${fusionCount} familles fusionnées, ${totalReduction} polices supprimées`)); console.log(chalk.cyan(`📊 Résultat: ${fontDataList.length} → ${mergedFonts.length} polices (${((totalReduction / fontDataList.length) * 100).toFixed(1)}% de réduction)`)); return { fontDataList: mergedFonts, pixelMatrices: mergedPixels }; } /** * Extrait les informations de police à partir du nom de fichier et du fichier d'index */ function extractFontInfoFromFilename(filename, fontIndexData) { // Supprimer l'extension et le suffixe "_a" const fontId = filename.replace('.png', '').replace('_a', ''); // Chercher la police dans l'index const fontData = fontIndexData[fontId]; if (!fontData) { console.warn(`⚠️ Police non trouvée dans l'index: ${fontId}`); // Fallback avec classification par nom const fontName = fontId.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const googleFontsUrl = `https://fonts.google.com/specimen/${fontName.replace(/\s+/g, '+')}`; return { name: fontName, id: fontId, imageName: fontId, // Ajouter imageName pour la cohérence family: "sans-serif", // par défaut google_fonts_url: googleFontsUrl }; } // Utiliser l'ID comme nom pour la cohérence avec les fichiers const fontName = fontId; // Utiliser l'ID au lieu de family const category = fontData.category; const googleFontsUrl = `https://fonts.google.com/specimen/${fontData.family.replace(/\s+/g, '+')}`; return { name: fontName, // Maintenant c'est l'ID (ex: "abeezee") id: fontId, imageName: fontId, // Ajouter imageName pour la cohérence family: category, google_fonts_url: googleFontsUrl, // Extraire les informations de poids et styles weights: fontData.weights || [], styles: fontData.styles || [], subsets: fontData.subsets || [], unicodeRange: fontData.unicodeRange || {} }; } /** * Normalise les données (équivalent de StandardScaler) */ function normalizeData(data) { const matrix = new Matrix(data); const means = matrix.mean('column'); const stds = matrix.standardDeviation('column'); // Éviter la division par zéro for (let i = 0; i < stds.length; i++) { if (stds[i] === 0) { stds[i] = 1; } } // Normaliser chaque colonne const normalized = matrix.clone(); for (let i = 0; i < normalized.rows; i++) { for (let j = 0; j < normalized.columns; j++) { normalized.set(i, j, (normalized.get(i, j) - means[j]) / stds[j]); } } return normalized.to2DArray(); } /** * Encode les catégories en vecteurs numériques */ function encodeCategories(fontDataList) { console.log(chalk.blue('🔢 Encoding categories to numerical vectors...')); // Obtenir toutes les catégories uniques const categories = [...new Set(fontDataList.map(font => font.family))]; console.log(chalk.cyan(`📊 Found ${categories.length} unique categories: ${categories.join(', ')}`)); // Créer un mapping catégorie -> index const categoryToIndex = {}; categories.forEach((category, index) => { categoryToIndex[category] = index; }); // Encoder chaque police avec un one-hot vector const encodedCategories = fontDataList.map(font => { const vector = new Array(categories.length).fill(0); const categoryIndex = categoryToIndex[font.family]; vector[categoryIndex] = 1; return vector; }); console.log(chalk.green(`✅ Encoded ${encodedCategories.length} fonts with ${categories.length} category dimensions`)); return { encodedCategories, categories, categoryToIndex }; } /** * Combine pixel matrices with category encodings */ function combinePixelAndCategoryData(pixelMatrices, encodedCategories) { console.log(chalk.blue('🔗 Combining pixel data with category encodings...')); const combinedData = []; for (let i = 0; i < pixelMatrices.length; i++) { const pixelVector = pixelMatrices[i]; const categoryVector = encodedCategories[i]; // Normaliser les pixels (0-1) const normalizedPixels = pixelVector.map(pixel => pixel / 255); // Combiner avec pondération const combinedVector = [ ...normalizedPixels.map(p => p * PIXEL_WEIGHT), // Pixels pondérés ...categoryVector.map(c => c * CATEGORY_WEIGHT) // Catégories pondérées ]; combinedData.push(combinedVector); } console.log(chalk.green(`✅ Combined data: ${pixelMatrices[0].length} pixels + ${encodedCategories[0].length} categories = ${combinedData[0].length} total dimensions`)); console.log(chalk.cyan(`📊 Weights: ${(PIXEL_WEIGHT * 100).toFixed(0)}% pixels, ${(CATEGORY_WEIGHT * 100).toFixed(0)}% categories`)); return combinedData; } /** * Charge toutes les données de polices depuis les PNGs */ async function loadAllFontData() { console.log(chalk.blue('🔄 Chargement des données de polices depuis les PNGs...')); // Create directory data si nécessaire await fs.mkdir(DATA_DIR, { recursive: true }); // Charger l'index des polices console.log(chalk.yellow('📖 Chargement de l\'index des polices...')); const fontIndexData = JSON.parse(await fs.readFile(FONT_INDEX_PATH, 'utf8')); console.log(chalk.green(`✅ Index chargé: ${Object.keys(fontIndexData).length} polices`)); // Trouver tous les fichiers PNG const files = await fs.readdir(PNGS_DIR); const pngFiles = files.filter(file => file.endsWith('_a.png')); if (pngFiles.length === 0) { throw new Error(`Aucun fichier PNG trouvé dans ${PNGS_DIR}`); } console.log(chalk.cyan(`📁 ${pngFiles.length} fichiers PNG trouvés`)); const fontDataList = []; const pixelMatrices = []; let rejectedCount = 0; // Traiter chaque fichier PNG for (let i = 0; i < pngFiles.length; i++) { const filename = pngFiles[i]; const pngPath = path.join(PNGS_DIR, filename); // Extract information de police depuis l'index const fontInfo = extractFontInfoFromFilename(filename, fontIndexData); // Charger la matrice de pixels avec validation de qualité const { pixelVector, quality } = await loadPNGAsMatrix(pngPath); if (pixelVector && quality) { fontDataList.push(fontInfo); pixelMatrices.push(pixelVector); // Ajouter les informations de qualité aux données de police fontInfo.quality = quality; if ((i + 1) % 50 === 0) { console.log(chalk.yellow(`⚡ ${i + 1}/${pngFiles.length} polices traitées...`)); } } else { rejectedCount++; console.log(chalk.red(`❌ Rejeté: ${filename} (qualité insuffisante)`)); } } console.log(chalk.green(`✅ ${fontDataList.length} polices chargées avec succès`)); console.log(chalk.red(`❌ ${rejectedCount} polices rejetées pour problèmes de qualité`)); console.log(chalk.blue(`📊 Matrice finale: ${pixelMatrices.length} polices × ${pixelMatrices[0]?.length || 0} pixels`)); return { fontDataList, pixelMatrices }; } /** * Génère les embeddings UMAP à partir des matrices de pixels */ function generateUMAPEmbedding(pixelMatrices) { console.log(chalk.blue('🔄 Génération des embeddings UMAP...')); // Normalize data (important pour UMAP) console.log(chalk.yellow('📊 Normalisation des données...')); const normalizedData = normalizeData(pixelMatrices); // Appliquer UMAP console.log(chalk.cyan(`🗺️ Application d'UMAP avec paramètres:`, UMAP_PARAMS)); const umap = new UMAP(UMAP_PARAMS); const embedding = umap.fit(normalizedData); console.log(chalk.green(`✅ UMAP terminé - Forme de l'embedding: ${embedding.length} × ${embedding[0]?.length || 0}`)); if (embedding.length > 0) { const xValues = embedding.map(row => row[0]); const yValues = embedding.map(row => row[1]); console.log(chalk.blue(`📊 Plage X: [${Math.min(...xValues).toFixed(2)}, ${Math.max(...xValues).toFixed(2)}]`)); console.log(chalk.blue(`📊 Plage Y: [${Math.min(...yValues).toFixed(2)}, ${Math.max(...yValues).toFixed(2)}]`)); } return embedding; } /** * Sauvegarde les données finales au format JSON */ async function saveTypographyData(fontDataList, embedding, categories) { console.log(chalk.blue('💾 Sauvegarde des données...')); // Combiner les données de polices et les coordonnées UMAP const finalData = []; for (let i = 0; i < fontDataList.length; i++) { const fontInfo = fontDataList[i]; const fontData = { ...fontInfo, x: embedding[i][0], y: embedding[i][1] }; finalData.push(fontData); } // Métadonnées const metadata = { generated_at: new Date().toISOString(), method: "umap_from_png_pixels_with_category_influence_and_font_fusion", total_fonts: finalData.length, font_fusion: { enabled: ENABLE_FONT_FUSION, prefix_length: FUSION_PREFIX_LENGTH, fusion_method: "average_pixels_with_metadata_aggregation" }, umap_params: UMAP_PARAMS, category_weights: { pixel_weight: PIXEL_WEIGHT, category_weight: CATEGORY_WEIGHT }, categories: categories, category_count: categories.length, data_source: "PNG pixel matrices (40x40) + category encoding + font family fusion" }; // Structure finale const outputData = { metadata, fonts: finalData }; // Sauvegarder await fs.writeFile(FULL_OUTPUT_PATH, JSON.stringify(outputData, null, 2), 'utf8'); console.log(chalk.green(`✅ Données sauvegardées dans ${FULL_OUTPUT_PATH}`)); // Statistics par catégorie const categoryStats = {}; for (const font of finalData) { const cat = font.family; categoryStats[cat] = (categoryStats[cat] || 0) + 1; } console.log(chalk.cyan('\n📊 Distribution par catégorie:')); for (const [cat, count] of Object.entries(categoryStats).sort(([,a], [,b]) => b - a)) { const percentage = ((count / finalData.length) * 100).toFixed(1); console.log(chalk.white(` ${cat}: ${count} polices (${percentage}%)`)); } } /** * Fonction principale */ async function main() { try { console.log(chalk.blue.bold('🎨 Génération UMAP pour la typographie à partir des matrices de pixels\n')); // 1. Load data de polices const { fontDataList, pixelMatrices } = await loadAllFontData(); if (fontDataList.length === 0) { throw new Error('Aucune donnée de police valide chargée'); } // 2. Fusionner les familles de polices const { fontDataList: mergedFontDataList, pixelMatrices: mergedPixelMatrices } = mergeFontFamilies(fontDataList, pixelMatrices); // 3. Encoder les catégories const { encodedCategories, categories, categoryToIndex } = encodeCategories(mergedFontDataList); // 4. Combiner pixels et catégories const combinedData = combinePixelAndCategoryData(mergedPixelMatrices, encodedCategories); // 5. Normaliser les données combinées const normalizedData = normalizeData(combinedData); // 6. Générer l'embedding UMAP const embedding = generateUMAPEmbedding(normalizedData); // 7. Sauvegarder les résultats await saveTypographyData(mergedFontDataList, embedding, categories); console.log(chalk.green.bold('\n🎉 Génération UMAP terminée avec succès !')); } catch (error) { console.error(chalk.red('💥 Erreur fatale:'), error.message); process.exit(1); } } // Lancer le script main();