Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何实现可复用的控制台“艺术字”打印 #43

Open
alienzhou opened this issue Nov 23, 2020 · 0 comments
Open

如何实现可复用的控制台“艺术字”打印 #43

alienzhou opened this issue Nov 23, 2020 · 0 comments

Comments

@alienzhou
Copy link
Owner

之前在使用一些开源项目时,经常会看到在控制台输出项目大大的 LOGO。例如:

  • hexo minos 主题启动时在控制台里会显示「MINOS」文案
  • fis3 启动时也会有显示「FIS」

添加这种大号「艺术字」可以达到「品牌露出」的效果,当然,也是程序员特有「情趣」的体现。 😄

但它们的实现方式无外乎把编排好的 Logo 通过 console.log 输出。这种方式问题在于它几乎没有任何复用能力,而且一些需要转义的情况还会导致字符串的可维护性极差。因此,我花了一个周末的时候,实现了一个易用的、可复用的控制台「艺术字」lib。这样,下次有新的需求,只需要把正常的文本传给它,它就可以帮你自动编排与打印

image

1. 目标

正如上节所说,目前一般项目的做法都是自定写一串特定的文本,例如 minos:

logger.info(`=======================================
███╗   ███╗ ██╗ ███╗   ██╗  ██████╗  ███████╗
████╗ ████║ ██║ ████╗  ██║ ██╔═══██╗ ██╔════╝
██╔████╔██║ ██║ ██╔██╗ ██║ ██║   ██║ ███████╗
██║╚██╔╝██║ ██║ ██║╚██╗██║ ██║   ██║ ╚════██║
██║ ╚═╝ ██║ ██║ ██║ ╚████║ ╚██████╔╝ ███████║
╚═╝     ╚═╝ ╚═╝ ╚═╝  ╚═══╝  ╚═════╝  ╚══════╝
=============================================`);

还有 fis3 这种由于需要添加转义所以显得凌乱不好维护的

logo = [
      '   /\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\  /\\\\\\\\\\\\\\\\\\\\\\     /\\\\\\\\\\\\\\\\\\\\\\   ',
      '   \\/\\\\\\///////////  \\/////\\\\\\///    /\\\\\\/////////\\\\\\        ',
      '    \\/\\\\\\                 \\/\\\\\\      \\//\\\\\\      \\///  ',
      '     \\/\\\\\\\\\\\\\\\\\\\\\\         \\/\\\\\\       \\////\\\\\\              ',
      '      \\/\\\\\\///////          \\/\\\\\\          \\////\\\\\\          ',
      '       \\/\\\\\\                 \\/\\\\\\             \\////\\\\\\      ',
      '        \\/\\\\\\                 \\/\\\\\\      /\\\\\\      \\//\\\\\\  ',
      '         \\/\\\\\\              /\\\\\\\\\\\\\\\\\\\\\\ \\///\\\\\\\\\\\\\\\\\\\\\\/   ',
      '          \\///              \\///////////    \\///////////     ',
      ''
    ].join('\n');

这种些方式都是通过「硬编码」来实现的,如果有了新项目或需求变动还得重新编排调整。

因此,准备实现一种能够根据输入的字符串进行自动排版展示的控制台「艺术字」打印库,例如通过 yo('yoo-hoo') 就会输出:

 /\\\    /\\\  /\\\\\\\\      /\\\\\\\\                /\\\    /\\\    /\\\\\\\\      /\\\\\\\\
 \/\\\   /\\\ /\\\_____/\\\  /\\\_____/\\\             \/\\\   \/\\\  /\\\_____/\\\  /\\\_____/\\\
   \/_\\\/\\\ \/\\\    \/\\\ \/\\\    \/\\\             \/\\\   \/\\\ \/\\\    \/\\\ \/\\\    \/\\\
      \/_\\\\  \/\\\    \/\\\ \/\\\    \/\\\  /\\\\\\\\\ \/\\\\\\\\\\\ \/\\\    \/\\\ \/\\\    \/\\\
         \/\\\  \/\\\    \/\\\ \/\\\    \/\\\ \/_______/  \/\\\____/\\\ \/\\\    \/\\\ \/\\\    \/\\\
          \/\\\  \/\\\    \/\\\ \/\\\    \/\\\             \/\\\   \/\\\ \/\\\    \/\\\ \/\\\    \/\\\
           \/\\\  \/_/\\\\\\\\\  \/_/\\\\\\\\\              \/\\\   \/\\\ \/_/\\\\\\\\\  \/_/\\\\\\\\\
            \/_/     \/_______/     \/_______/               \/_/    \/_/    \/_______/     \/_______/

下次如果文案改了,直接换下字符串参数就行 —— yo('new-one')

/\\\\\     /\\\  /\\\\\\\\\\  /\\\  \\\  \\\                /\\\\\\\\    /\\\\\     /\\\  /\\\\\\\\\\
\/\\\ \\\  \/\\\ \/\\\_____/  \/\\\  \\\  \\\              /\\\_____/\\\ \/\\\ \\\  \/\\\ \/\\\_____/
 \/\\\ /\\\ \/\\\ \/\\\        \/\\\  \\\  \\\             \/\\\    \/\\\ \/\\\ /\\\ \/\\\ \/\\\
  \/\\\  /\\\ /\\\ \/\\\\\\\\\\ \/\\\  \\\  \\\  /\\\\\\\\\ \/\\\    \/\\\ \/\\\  /\\\ /\\\ \/\\\\\\\\\\
   \/\\\ \/\\\ /\\\ \/\\\_____/  \/\\\  \\\  \\\ \/_______/  \/\\\    \/\\\ \/\\\ \/\\\ /\\\ \/\\\_____/
    \/\\\ \ /\\\ \\\ \/\\\        \/\\\ \\\\\ \\\             \/\\\    \/\\\ \/\\\ \ /\\\ \\\ \/\\\
     \/\\\  \/_\\\\\\ \/\\\\\\\\\\ \/\\\\\__/\\\\\             \/_/\\\\\\\\\  \/\\\  \/_\\\\\\ \/\\\\\\\\\\
      \/_/    \/____/  \/________/  \/_/      \/_/                \/_______/   \/_/    \/____/  \/________/

总结来说,就是实现一个通用的、可复用的控制台「艺术字」打印功能。基于这个目标开发了 yoo-hoo 这个库。

下面来说说大致怎么实现。

2. 如何实现

和其他字体显示的需求类似,我们可以将功能抽象为三个部分:

  1. 字体库的生成
  2. 字体的排版
  3. 字体的渲染

这里我们先说一下字体的渲染。

2.1. 字体渲染

之所以先说这部分,是因为它会影响排版信息的输出格式。

其实字体渲染这部分并没有什么特别的,我们在控制台这个环境,受限于 API,基本就是使用 console.log 来将内容「渲染」到屏幕上。不过,正是这里的「渲染」形式的限制,会倒推我们的排版方式。

我们知道,控制台基本都是单行顺序渲染的,大致就是「Z」字型。同时,由于我们的「艺术字」会占据多行,所以最终的渲染不是按单个字顺序渲染的,需要先排好版,然后按行来逐步渲染到屏幕上。

这有点像是咱们常见的打印机。如果你要打印一个苹果,它会从上往下逐步打印出这个苹果,而不是直接像盖章那样��直接印刷一个苹果。

下面我们会先介绍字体库的生成,而不是紧接挨着的字体排版。因为排版是一个承上启下的过程,当我们确定了上下游环节,这块的逻辑自然也就确定了。

2.2. 字体库生成

当我们想要实现可复用能力时,因此我们需要找到或者抽象出系统内逻辑上的最小可复用单元 —— 在这里显然就是字符。简单来说,对于输入字符串 JS 时,如果我们能找到对应的 J 和 S 的字符表示形式,辅以排版,理论上就有能力实现我们的目标。这有点像是咱们老祖宗的活字印刷术。

所以在字体库这里,我们会有一个字义与字型的映射。这个其实和咱们前端常见的字体文件内格式的思想一样,都需要有这么一个映射关系。

字型哪里来呢?好吧,我也是用了一个笨办法 —— 自己「手绘」😂。举个例子,下面就是我「手绘」的 1:

1
  /\\\
/\\\\\\
\/__/\\\
    \/\\\
     \/\\\
      \/\\\
      /\\\\\\\
      \/_____/

绘制的过程是枯燥的,好再很多字型的局部是有一定复用的,简化了这项繁琐的工作。当然,这只是一次性的工作,一旦创建好一类「字体」,以后就不需要再重复这项工作了。

我把上面这个内容存在一个单独的文件中,目前直接以 .txt 为后缀,这就是我们的字体原始格式。之所以不放在 .js 中,是因为 JavaScript 中 \ 是想要转义的,这样文本的视觉和最后的呈现效果就不一致了,不利于调试和维护。

原始字体文件分为两部分:

  • 上面第一行是字义,支持一个多个字义对应一个图形。例如 ·* 我使用了同一个图形。多个字义间空格分割,不换行。
  • 除去第一行,剩下的内容就是字型。

理论上,我们可以以这个原始字体文件来作为字体库了,通过 NodeJS 中的 fs 模块读取并解析文件内容即可得到映射关系。

但我希望它也能在非 NodeJS 环境(例如浏览器)中使用,所以不能依赖 fs 模块。这里做了一个原始文件的解析脚本,生成对应的 JS 模块。由于我们并不直接维护这些生成的 JS 模块,所以它的可读性不重要,可以设计数据格式的时候可以完全面向后续的排版流程。

首先实现一个简单的解析器来解析第一行的字义。这也类似一个词法解析器,但由于语法规则极其弱智(简单),所以也就不用多说了,大致如下:

const parseDefinition = function (line: string) {
    let token = '';
    const defs: string[] = [];
    for (const char of line) {
        if (char === ' ' && token) {
            defs.push(token);
            token = '';
        }
        if (char !== ' ') {
            token += char;
        }
    }
    if (token) {
        defs.push(token);
    }
    return defs;
}

下面就是处理字型部分。之所以需要处理字型,是因为上面提到的转义问题。由于我们在原始格式中使用了 \ 来进行字型展示,而将其直接放入生成的 JS 文件中这个 \ 就变为了转义符,要想正常展示需要变为 \\。一种方式是正则匹配,将所有源文本中的 \ 替换为 \\ 再写入。但我选择了另一种方式。

将字符通过 .charCodeAt 方法转为 char code 存储,读取字体信息时再通过 String.fromCharCode 转回来。原来的字符串变成了数字类型的数组,这样就没有特殊字符的问题了。最后,通过拼接文本并生成 JS 文件来将原始的、利于人维护的字体文件,转成了编译 JS 工作的模块。

const arrayToString = <T>(arr: T[]) => '[' + arr.map(d => `'${d}'`).join(',') + ']';

const text = parsedFonts.reduce((t, f, idx) => {
    return t + (
        '\n/**\n'
        + f.content
        + '\n*/\n'
        + `fonts[${idx}] = {\n`
        + `  defs: ${arrayToString(f.defs)},\n`
        + `  codes: ${arrayToString(f.codes)}\n`
        + '};\n'
    );
}, '');
const moduleText = (
    'const fonts = [];\n'
    + text
    + 'module.exports.fonts = fonts;\n'
);

fs.writeFileSync(fontFilepath, moduleText, 'utf-8');

其中 defs 就是这个字型对应的字义列表,codes 则是字型的 char code 数组,所有的字体都被放在一个 JS 文件中。

这里提一下,第 3 行的 parsedFonts 就是遍历所有原始字体文件解析到的内容,因此得到这部分也是需要通过 NodeJS 的 fs 模块来递归读取源文件目录下的字体文件的。算是基操,就不用展开了。

由于这部分是可以提前解析编译的,一旦生成了 JS 模块后就不会对 NodeJS 运行时有依赖,所以保证了其依然可以运行在浏览器中。

2.3. 字体的排版

我们的字体格式确定了,目标的渲染方式也确定了。最后就可以填充这部分的逻辑实现了。

具体排版上会遇到一些细节点,例如不等高字体的空行填充、最大行宽的换行判断(需要用户执行行宽),不过这些都是小点,处理也不太复杂。这里可能介绍一下稍有特殊的一块 —— 字间距调整。

我们知道,一些艺术字的倾斜程度可能很大,例如这个字符「1」:

  /\\\
/\\\\\\
\/__/\\\
    \/\\\
     \/\\\
      \/\\\
      /\\\\\\\
      \/_____/

如果按简单的矩形型包围盒来分配空间,大概会是下面这样:

image

前后两个字体,即使设置为最小间距(0),仍然会距离很远,这样就破坏了一定的显示效果。例如上图中我两个包围盒间距其实只有 1,但看起来就很大。我们实际希望的可能是下面这样:

image

间距为 1 时,两个字符「1」调整为在最近的地方间距为 1。如果要更宽的效果可以设置更多间距。这个处理起来主要就是需要算出最大的「挤压空间」(即两个盒子最大支持的交叉空间)。最开始渲染的时候说了,我们是按 console 出的行来存储的与打印的,举个例子,这个「1」高度为 8 ,所以渲染的时候就是一个 8 个元素的字符串数组:

const lines = [
    '  /\\\',
    '/\\\\\\',
    '\/__/\\\',
    '    \/\\\',
    '     \/\\\',
    '      \/\\\',
    '      /\\\\\\\',
    '      \/_____/',
];

渲染的时候直接 lines.forEach(l => console.log(l)) 即可。

💣 注意,为了便于读者阅读,上面的 lines 数组内的字符串我没有加上转义,它是不合法的!只是为了展示起来更便于阅读理解,实际中不能这么写。

最大缩进(缩进这个词不准确,但希望大家能够理解那个意思)的计算只需要知道之前的每个 line 尾部对应有多少空格,同时需要再其后新添加字符每个 line 前面又分别有多少空格,综合两者,再遍历所有的 line 取一个最小值即可:

// calc the prefix space
const prefixSpace = function (str: string) {
    const matched = /^\s+/gu.exec(str);

    return matched ? matched[0].length : 0;
};

// calc the tail space
const tailSpace = function (str: string) {
    const matched = /\s+$/gu.exec(str);

    return matched ? matched[0].length : 0;
};

// calc how many spaces need for indent for layout
// overwise the gap between two characters will be different
const calcIndent = function (lines: string[], charLines: string[]): number {
    // maximum indent that won't break the layout
    let maxPossible = Infinity;

    for (let i = 1; i < lines.length; i++) {
        const formerTailNum = tailSpace(lines[i]);
        const latterPrefixNum = prefixSpace(charLines[i]);

        maxPossible = Math.min(maxPossible, formerTailNum + latterPrefixNum);
    }

    return maxPossible;
};

最后 calcIndent 方法返回的就是新字符需要向前缩进(或者说缩紧)的值。最后渲染的时候根据这个值来调整每行连接时添加的空格数即可。

捎带一提,之前的字体格式 load 进来会被转换为类似字典的格式 —— 字义作为 key,字型等一系列属性作为 value:

const dictionary = {
    'a': {
        lines: [...],
        width: ...,
        height: ...,
    },
    'b': {
        ...
    },
    ...
}

这样遍于 split 完用户传入的字符串后,更简单的索引到对应的字型和字体信息。

2.4. 其他

当然,其他还会有一些工作,包括

  • 支持颜色
  • 支持返回排版完的 lines 让用户自己渲染
  • 支持用户自定义调整字间距

这些目前实现上遇到的问题不大,篇幅原因也就不说了。具体的代码可以在 Github 上看到。

3. 总结

实现可复用的控制台“艺术字”功能,总的来说并没有太多复杂的点,整体的流程模型就是

生成字体库 --> 字体排版 --> 渲染文本

这对于前端来说应该是非常好理解的。

做这个项目也确实是自己在工作中希望给一些库加上这种 logo 或者 banner 展示,但每次重复枯燥的工作确实令人反感。所以想了下可行性之后就搞了 yoo-hoo 这么个小玩意儿,如果大家也遇到类似的问题,希望能有所帮助。

npm i yoo-hoo

4. 最后

目前 [email protected] 内置了一套 26 个字母(A-Z)、10 个数字(0-9)、· * - | 这些字符的字体库。

考虑到单一的字型和有限的字体量肯定不能满足所有需求,所以开发时代码结构就留下了支持外部扩展的模式。

后续可以把 2.2 节中的字体源文件解析工具独立出来,支持用户「手绘」自己的字型,用工具生成对应格式后,将字体的 JS 模块传入 yo 方法中作为扩展字体加载。

字体源文件的「手绘」虽有成本,但所见即所得,编写难度不大 🐶 同时也算是一劳永逸。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant