Flutter 文本解读 9 | 打造 Icon 图标字体创建工具

2,517 阅读3分钟

零、前言

1. 前情简介

上一节写了一个小工具,通过 icon_builder.dart 来自动生成对应图标相关的 dart 文件。这样我们从引用自定义的图标只需要: 下载 -> 拷贝-> 生成

现在为止,功能还是比较单薄的,比如字体还需要自己在 pubspec.yaml 中配置,其实作为一个脚本而言,最好的就是一键 OK,所以 pubspec.yaml 中配置也可以通过代码自动完成。再比如说,多个字体图标文件怎么办,如何能更方便地支持多图标字体。


2.本系列其他文章

一、 pubspec.yaml 中配置自动生成

1.需求分析
1. 如果没有 fonts: 节点,则创建 fonts: 节点
2. 在 [ pubspec.yaml ] 中自动对 fonts: 节点进行字体图标配置
3. 如果已存在 该字体图标配置 ,则不处理


2.分析 pubspec.yaml

首先说说思路,pubspec.yaml 是一行行配置的,所以我们可以读行。寻找到 fonts 行,看看有没有 该字体图标配置,如果没有,则在 fonts 行的下一行添加对应节点,最后将字符串行列表写回 pubspec.yaml 即可。那么寻找 fonts 呢?也许你会想:用 contains 不就行了吗。但这样的匹配并不精确,可以会误判而出问题,匹配最好使用正则。通过 ^ fonts\: 就可以匹配到以它开头的字符。


为了避免注释对匹配的干扰,在处理时,通过 RegExp(r'#.*') 将行中的注释临时去掉。fontLinefamilyLine 分别记录 fonts该字体图标配置 对应的行索引。

void handleYaml(
    {String family = 'TolyIcon',
    String asset = 'assets/iconfont/iconfont.ttf'}) async {
  File yamlFile = File(path.join(Directory.current.path, 'pubspec.yaml'));
  List<String> yamlLines = await yamlFile.readAsLines();
  RegExp fontsReg = RegExp(r'^  fonts\:');
  RegExp familyReg = RegExp(r'\- family:.*' + family);
  RegExp commentReg = RegExp(r'#.*');

  int fontLine = -1;
  int familyLine = -1;
  for (int i = 0; i < yamlLines.length; i++) {
    // 去除注释
    String pureLine = yamlLines[i].replaceAll(commentReg, '');
    if (fontsReg.hasMatch(pureLine)) {
      fontLine = i;
    }
    if (familyReg.hasMatch(pureLine)) {
      familyLine = i;
    }
  }
  print('fontLine:$fontLine-----------familyLine:$familyLine---------',);
}

如下处理,当 fontLine == -1,则表示 fonts: 节点 不存在,则添加 fonts: 节点和配置。familyLine == -1, 则表示 配置不存在,则添加配置。否则,不处理。

  String config =
"""
    - family: $family
      fonts:
        - asset: $asset""";

  if(fontLine == -1){
    // fontLine 不存在,则添加 fonts: 节点和配置
    yamlLines.add('  fonts: ');
    yamlLines.add( config );
  }else{
    if(familyLine == -1){
      // familyLine 不存在,则添加配置
      yamlLines.insert(fontLine + 1, config);
    }else{
      // 否则说明该图标字体已配置,无须处理
      return;
    }
  }
  await yamlFile.writeAsString(yamlLines.join('\n'));

这样,在 icon_builder.dart 运行后,pubspec.yaml 就会自动把图标字体节点配置好。


3.可配置参数

可以将 字体名字体资源文件夹产出位置 作为配置的参数。这样可以提取一个 buildAnIconFont 方法用于构建一个 字体图标 文件。

main() async {
  String cssPath = 'assets/iconfont/iconfont.css'; // 样式路径
  String fontPath = 'assets/iconfont/iconfont.ttf'; // 字体路径
  String fontName = 'TolyIcon'; // 字体名称
  String dist = 'generate/icon'; //输出文件地址

  await buildAnIconFont(cssPath, fontPath, fontName, dist);
}

注意一点,.css样式文件在生成 .dart 文件后,其使命就完成了,可以删除。

Future<void> buildAnIconFont(String fontDir, String fontName, String dist) async {
  String asset = '$fontDir/$fontName.ttf'; //输出文件地址
  File target = File(path.join(Directory.current.path, fontDir, '$fontName.css'));
  if(!target.existsSync()) return; // 样式文件不存在,则直接返回
  
  String str = await target.readAsString();
  List<String> names = [];
  List<String> unicodes = [];
  StringScanner _scanner = StringScanner(str);

  while (!_scanner.isDone) {
    if (_scanner.scan(RegExp(r'\.icon-(.*?):'))) {
      String word = _scanner.lastMatch[1];
      names.add(word);
    }

    if (_scanner.scan(RegExp(r'"\\(.*?)"'))) {
      String word = _scanner.lastMatch[1];
      unicodes.add(word);
    }

    if (!_scanner.isDone) {
      _scanner.position++;
    }
  }

  assert(names.length == unicodes.length);

  Map<String, String> iconMap = Map.fromIterables(names, unicodes);
  String code = getCode(iconMap, fontName: fontName);
  await save2File(code, filePath: dist, fontName: fontName);
  await handleYaml(family: fontName, asset: asset);
  await target.delete(); // 删除样式文件
}

二、 多个字体图标文件处理

1.多图标字体分析

其实在图标网站可以通过项目 来管理图标,一般一个项目一个图标文件就够了。但如果真的有多个图标文件的需求,也可以将 icon_builder.dart 再优化一些。


就目前的小工具而言,再引入一个 Ruby 的字体文件,构建一下。也可以自动生成对应的 .dart 文件,以及自动配置 fonts 节点。

不过还需要手动修改些配置,有一丢丢的小麻烦。想要不麻烦,那就用规范来减少配置。现在要求 .css 和 .ttf 的文件名相同,且文件名即为字体名。这样就可以遍历文件夹,解析文件名,从而减少配置。


2.代码处理

多字体文件放置如下,只需要配置资源目录输出目录 即可。

main() {
  String resDir = 'assets/iconfont'; // 字体位置
  String dist = 'generate/icon'; //输出文件地址
  parserResDir(resDir, dist);
}

void parserResDir(String resDir,String dist) async{
  Directory dir = Directory(path.join(Directory.current.path, resDir));
  List<FileSystemEntity> files = dir.listSync();

  for(int i = 0; i < files.length ; i++){
    File file = files[i];
    if (file is File && file.path.endsWith('.css')) {
      String fontName = path.basenameWithoutExtension(file.path);
      await buildAnIconFont(resDir,fontName, dist);
    }
  }
}

这样运行 icon_builder.dart 过后,1. css 文件会被删除;2. 相应的.dart 文件会自动生成;3. pubspec.yaml 会自动配置。可以说已经很不错了。


3.字体类的融合

如果想要使用两种字体,但只想通过一个类进行调用,这样就不会生成过多的类,使用起来方便些。其实处理起来也很简单,设置两个标识,用于是否开启 mergeClass 以及融合后的类名。融合后效果如下,两个字体通过一个 .dart 文件管理。

bool mergeClass = true;
String className = 'TolyIcon';


这样就可以通过一个类,同时使用多个字体文件:

image-20210123212746791

Wrap(
  spacing: 20, 
  children: [
    Icon(TolyIcon.icon_collect, size: 50,),
    Icon(TolyIcon.icon_ruby, size: 50,)
]);

三、icon_builder.dart 完整代码

代码一共也就 170 行,但功能还不错。随便写写,代码结构上有待优化,其中包含了很多文件处理,字符串分析的知识,这些都挺好玩的。有什么更好的想法,也可以和我在群里交流。其实按照这个逻辑做成 AS 插件Gradle 插件也未尝不可。不过通过一个小脚本也比较方便,运行一下就 OK 了 。谢谢观看 ~

import 'dart:io';
import 'package:string_scanner/string_scanner.dart';
import 'package:path/path.dart' as path;

/// create by 张风捷特烈 on 2021/1/22
/// contact me by email 1981462002@qq.com
/// 说明: iconfont 解析构造器

bool deleteCss = true; // 是否删除 css
bool mergeClass = true; // 多个字体文件时是否融合成一个类
String className = 'TolyIcon'; // 融合成一个类时类名
String resDir = 'assets/iconfont'; //资源文件地址
String dist = 'generate/icon'; //输出文件地址

main() async {
  File target = File(path.join(Directory.current.path, 'lib', dist, '$className.dart'));
  if(mergeClass&&target.existsSync()) {
    await target.writeAsString('');
  }

  await parserResDir(resDir, dist);

  if (mergeClass) {
    String content = await target.readAsString();
    String result = """import 'package:flutter/widgets.dart';
//Power By 张风捷特烈 --- Generated file. Do not edit.

class $className {
    $className._();
""";
    result += content;
    result += "}";
    await target.writeAsString(result);
  }
}

Future<void> parserResDir(String resDir, String dist) async {
  Directory dir = Directory(path.join(Directory.current.path, resDir));
  List<FileSystemEntity> files = dir.listSync();

  for (int i = 0; i < files.length; i++) {
    File file = files[i];
    if (file is File && file.path.endsWith('.css')) {
      String fontName = path.basenameWithoutExtension(file.path);
      await buildAnIconFont(resDir, fontName, dist);
    }
  }
}

Future<void> buildAnIconFont(
    String resDir, String fontName, String dist) async {
  String fontPath = '$resDir/$fontName.ttf';
  String cssPath = '$resDir/$fontName.css';
  File target = File(path.join(Directory.current.path, cssPath));
  if (!target.existsSync()) return;

  String str = await target.readAsString();

  List<String> names = [];
  List<String> unicodes = [];
  StringScanner _scanner = StringScanner(str);

  while (!_scanner.isDone) {
    if (_scanner.scan(RegExp(r'\.icon-(.*?):'))) {
      String word = _scanner.lastMatch[1];
      names.add(word);
    }

    if (_scanner.scan(RegExp(r'"\\(.*?)"'))) {
      String word = _scanner.lastMatch[1];
      unicodes.add(word);
    }

    if (!_scanner.isDone) {
      _scanner.position++;
    }
  }
  assert(names.length == unicodes.length);

  Map<String, String> iconMap = Map.fromIterables(names, unicodes);
  String code = getCode(iconMap, fontName: fontName);
  await save2File(code, filePath: dist, fontName: fontName);
  await handleYaml(family: fontName, asset: fontPath);
  if (deleteCss) await target.delete();
  // 删除样式文件
  print('创建 $fontName 完毕!');
}

Future<void> handleYaml({String family = 'TolyIcon',
    String asset = 'assets/iconfont/iconfont.ttf'}) async {
  File yamlFile = File(path.join(Directory.current.path, 'pubspec.yaml'));
  List<String> yamlLines = await yamlFile.readAsLines();
  RegExp fontsReg = RegExp(r'^  fonts\:');
  RegExp familyReg = RegExp(r'\- family:.*' + family);
  RegExp commentReg = RegExp(r'#.*');

  int fontLine = -1;
  int familyLine = -1;
  for (int i = 0; i < yamlLines.length; i++) {
    // 去除注释
    String pureLine = yamlLines[i].replaceAll(commentReg, '');
    if (fontsReg.hasMatch(pureLine)) {
      fontLine = i;
    }
    if (familyReg.hasMatch(pureLine)) {
      familyLine = i;
    }
  }

  String config = """
    - family: $family
      fonts:
        - asset: $asset""";

  if (fontLine == -1) {
    // fontLine 不存在,则添加 fonts: 节点和配置
    yamlLines.add('  fonts: ');
    yamlLines.add(config);
  } else {
    if (familyLine == -1) {
      // familyLine 不存在,则添加节点和配置
      yamlLines.insert(fontLine + 1, config);
    } else {
      // 否则说明该图标字体已配置,无须处理
      return;
    }
  }
  await yamlFile.writeAsString(yamlLines.join('\n'));
}

Future<void> save2File(String content,
    {String filePath: 'generate/icon', String fontName: 'TolyIcon'}) async {

  if(mergeClass){
    File target = File(
        path.join(Directory.current.path, 'lib', filePath, '$className.dart'));
    if (!target.existsSync()) {
      await target.create(recursive: true);
    }
    await target.writeAsString(content,mode: FileMode.append);
  }else{
    File target = File(
        path.join(Directory.current.path, 'lib', filePath, '$fontName.dart'));
    if (!target.existsSync()) {
      await target.create(recursive: true);
    }
    await target.writeAsString(content);
  }
}

String getCode(Map<String, String> iconMap, {String fontName: 'TolyIcon'}) {
  String content = '';
  iconMap.forEach((key, value) {
    content +=
        """static const IconData $key = IconData( 0x$value, fontFamily: "$fontName");\n""";
  });

  if (mergeClass) {
    return content;
  }
  String result = """import 'package:flutter/widgets.dart';
//Power By 张风捷特烈 --- Generated file. Do not edit.

class $fontName {
    $fontName._(); 
""";
  result += content;
  result += "}";
  return result;
}

@张风捷特烈 2021.01.24 未允禁转
我的公众号:编程之王
联系我--邮箱:1981462002@qq.com -- 微信:zdl1994328
~ END ~