// Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // ignore_for_file: avoid_print import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:mustache_template/mustache.dart'; import 'fonts.pb.dart'; const String _generatedAllPartsFilePath = 'lib/src/google_fonts_all_parts.dart'; String _generatedPartFilePath(String part) => 'lib/src/google_fonts_parts/part_$part.dart'; const String _familiesSupportedPath = 'generator/families_supported'; const String _familiesDiffPath = 'generator/families_diff'; Future main() async { print('Getting latest font directory...'); final Uri protoUrl = await _getProtoUrl(); print('Success! Using $protoUrl'); final Directory fontDirectory = await _readFontsProtoData(protoUrl); _computeVariantCounts(fontDirectory); print('\nValidating font URLs and file contents...'); await _verifyUrls(fontDirectory); print(_success); print('\nDetermining font families delta...'); final familiesDelta = _FamiliesDelta(fontDirectory); print(_success); print('\nGenerating $_familiesSupportedPath & $_familiesDiffPath ...'); File(_familiesSupportedPath).writeAsStringSync(familiesDelta.printableSupported()); File(_familiesDiffPath).writeAsStringSync(familiesDelta.markdownDiff()); print(_success); print('\nGenerating $_generatedAllPartsFilePath and part files...'); _generateDartCode(fontDirectory); print(_success); print('\nFormatting $_generatedAllPartsFilePath and part files...'); await Process.run('dart', ['format', 'lib']); print(_success); } const String _success = 'Success!'; /// Gets the latest font directory. /// /// Versioned directories are hosted on the Google Fonts server. We try to fetch /// each directory one by one until we hit the last one. We know we reached the /// end if requesting the next version results in a 404 response. /// Other types of failure should not occur. For example, if the internet /// connection gets lost while downloading the directories, we just crash. But /// that's okay for now, because the generator is only executed in trusted /// environments by individual developers. Future _getProtoUrl({int initialVersion = 7}) async { var directoryVersion = initialVersion; Uri url(int directoryVersion) { final String paddedVersion = directoryVersion.toString().padLeft(3, '0'); return Uri.parse('https://fonts.gstatic.com/s/f/directory$paddedVersion.pb'); } var didReachLatestUrl = false; final httpClient = http.Client(); while (!didReachLatestUrl) { final http.Response response = await httpClient.get(url(directoryVersion)); if (response.statusCode == 200) { directoryVersion += 1; } else if (response.statusCode == 404) { didReachLatestUrl = true; directoryVersion -= 1; } else { throw Exception('Request failed: $response'); } } httpClient.close(); return url(directoryVersion); } Future _verifyUrls(Directory fontDirectory) async { final int totalFonts = fontDirectory.family .map((FontFamily f) => f.fonts.length) .reduce((int a, int b) => a + b); final client = http.Client(); var i = 1; for (final FontFamily family in fontDirectory.family) { for (final Font font in family.fonts) { final urlString = 'https://fonts.gstatic.com/s/a/${_hashToString(font.file.hash)}.ttf'; final Uri url = Uri.parse(urlString); await _tryUrl(client, url, font); print('Verified URL ($i/$totalFonts): $urlString'); i += 1; } } client.close(); } Future _tryUrl(http.Client client, Uri url, Font font) async { try { final http.Response fileContents = await client.get(url); final int actualFileLength = fileContents.bodyBytes.length; final actualFileHash = sha256.convert(fileContents.bodyBytes).toString(); if (font.file.fileSize != actualFileLength || _hashToString(font.file.hash) != actualFileHash) { throw Exception('Font from $url did not match length of or checksum.'); } } catch (e) { print('Failed to load font from url: $url'); rethrow; } } String _hashToString(List bytes) { var fileName = ''; for (final byte in bytes) { final String convertedByte = byte.toRadixString(16).padLeft(2, '0'); fileName += convertedByte; } return fileName; } // Utility class to track font family deltas. // // [fontDirectory] represents a possibly updated directory. class _FamiliesDelta { _FamiliesDelta(Directory fontDirectory) { _init(fontDirectory); } late final Set added; late final Set removed; late final Set all; void _init(Directory fontDirectory) { // Currently supported families. final familiesSupported = Set.from(File(_familiesSupportedPath).readAsLinesSync()); // Newly supported families. all = Set.from(fontDirectory.family.map((FontFamily item) => item.name)); added = all.difference(familiesSupported); removed = familiesSupported.difference(all); } // Printable list of supported font families. String printableSupported() => all.join('\n'); // Diff of font families, suitable for markdown // (e.g. CHANGELOG, PR description). String markdownDiff() { final Iterable addedPrintable = added.map((String family) => ' - `$family`'); final Iterable removedPrintable = removed.map((String family) => ' - `$family`'); var diff = ''; if (addedPrintable.isNotEmpty) { diff += '- Added fonts:\n'; diff += addedPrintable.join('\n'); diff += '\n'; } if (removedPrintable.isNotEmpty) { diff += '- Removed fonts:\n'; diff += removedPrintable.join('\n'); diff += '\n'; } return diff; } } void _computeVariantCounts(Directory fontDirectory) { final int families = fontDirectory.family.length; // Pre-dedup counts var preStatic = 0; var preVariable = 0; for (final FontFamily family in fontDirectory.family) { for (final Font font in family.fonts) { if (font.isVf) { preVariable += 1; } else { preStatic += 1; } } } print( 'Found $families font families, composed of $preStatic static variants and $preVariable variable variants.', ); // Post-dedup counts var postStatic = 0; var postVariable = 0; for (final FontFamily family in fontDirectory.family) { final List filtered = _deduplicateFonts(family.fonts); for (final font in filtered) { if (font.isVf) { postVariable += 1; } else { postStatic += 1; } } } print( 'After deduplication: composed of $postStatic static variants and $postVariable variable variants.', ); } void _generateDartCode(Directory fontDirectory) { final methods = >[]; for (final FontFamily item in fontDirectory.family) { final String family = item.name; final String familyNoSpaces = family.replaceAll(' ', ''); final String familyWithPlusSigns = family.replaceAll(' ', '+'); final String methodName = _familyToMethodName(family); const themeParams = [ 'displayLarge', 'displayMedium', 'displaySmall', 'headlineLarge', 'headlineMedium', 'headlineSmall', 'titleLarge', 'titleMedium', 'titleSmall', 'bodyLarge', 'bodyMedium', 'bodySmall', 'labelLarge', 'labelMedium', 'labelSmall', ]; final List filteredVariants = _deduplicateFonts(item.fonts); methods.add({ 'methodName': methodName, 'part': methodName[0].toUpperCase(), 'fontFamily': familyNoSpaces, 'fontFamilyDisplay': family, 'docsUrl': 'https://fonts.google.com/specimen/$familyWithPlusSigns', 'fontUrls': >[ for (final Font variant in filteredVariants) { 'variantWeight': variant.weight.start, 'variantStyle': variant.italic.start.round() == 1 ? 'italic' : 'normal', 'hash': _hashToString(variant.file.hash), 'length': variant.file.fileSize, }, ], 'themeParams': >[ for (final String themeParam in themeParams) {'value': themeParam}, ], }); } // Part font methods by first letter. final methodsByLetter = >>{}; final allParts = >[]; for (final map in methods) { final methodName = map['methodName'] as String; final String firstLetter = methodName[0]; if (!methodsByLetter.containsKey(firstLetter)) { allParts.add({ 'partFilePath': _generatedPartFilePath(firstLetter).replaceFirst('lib/src/', ''), }); methodsByLetter[firstLetter] = >[map]; } else { methodsByLetter[firstLetter]!.add(map); } } // Generate part files. final partTemplate = Template( File('generator/google_fonts_part.tmpl').readAsStringSync(), htmlEscapeValues: false, ); methodsByLetter.forEach((String letter, List> methods) async { final String renderedTemplate = partTemplate.renderString({ 'part': letter.toUpperCase(), 'method': methods, }); _writeDartFile(_generatedPartFilePath(letter), renderedTemplate); }); // Generate main file. final template = Template( File('generator/google_fonts.tmpl').readAsStringSync(), htmlEscapeValues: false, ); final String renderedTemplate = template.renderString(>>{ 'allParts': allParts, 'method': methods, }); _writeDartFile(_generatedAllPartsFilePath, renderedTemplate); } void _writeDartFile(String path, String content) { File(path).writeAsStringSync(content); } /// Remove variable-font entries when a static entry of the same /// weight/italic exists. Keep variable only if no static equivalent. /// Also deduplicate static variants by keeping only default width (100.0). /// // TODO(guidezpl): Add explicit variable-font support, see // https://github.com/flutter/flutter/issues/174575 and // https://github.com/flutter/flutter/issues/174567 // TODO(guidezpl): Add font width/stretch support to google_fonts API to properly // handle width variants (condensed, normal, extended) instead of deduplicating them. List _deduplicateFonts(List fonts) { // First, identify variable fonts, and group static fonts by weight/italic to // find the best width variant. final List variableFonts = []; final Map> staticGroups = {}; for (final font in fonts) { if (font.isVf) { variableFonts.add(font); } else { final key = '${font.weight.start}_${font.italic.start.round()}'; staticGroups.putIfAbsent(key, () => []).add(font); } } // For each static group, keep a single width (default to 100.0) variant. final filteredVariants = []; for (final List group in staticGroups.values) { Font? defaultWidthVariant; for (final font in group) { if (font.width.start == 100.0) { defaultWidthVariant = font; break; } } if (defaultWidthVariant == null) { // If no default width, and more than one variant, we'll have to revise // this logic to understand which width to prefer. assert(group.length == 1); } filteredVariants.add(defaultWidthVariant ?? group.first); } // Now, for each variable font, check if a static equivalent exists. // If so, skip the variable font. for (final variant in variableFonts) { var hasStaticEquivalent = false; for (final other in fonts) { if (!other.isVf && other.weight.start == variant.weight.start && other.italic.start.round() == variant.italic.start.round()) { hasStaticEquivalent = true; break; } } if (!hasStaticEquivalent) { filteredVariants.add(variant); } } return filteredVariants; } String _familyToMethodName(String family) { final List words = family.split(' '); for (var i = 0; i < words.length; i++) { final String word = words[i]; final isFirst = i == 0; final isUpperCase = word == word.toUpperCase(); words[i] = (isFirst ? word[0].toLowerCase() : word[0].toUpperCase()) + (isUpperCase ? word.substring(1).toLowerCase() : word.substring(1)); } return words.join(); } Future _readFontsProtoData(Uri protoUrl) async { final http.Response fontsProtoFile = await http.get(protoUrl); return Directory.fromBuffer(fontsProtoFile.bodyBytes); }