如何實(shí)現(xiàn)可復(fù)用的控制臺(tái)“藝術(shù)字”打印功能

之前在使用一些開(kāi)源項(xiàng)目時(shí),經(jīng)常會(huì)看到在控制臺(tái)輸出項(xiàng)目大大的 LOGO。例如:

  • hexo minos 主題啟動(dòng)時(shí)在控制臺(tái)里會(huì)顯示「MINOS」文案
  • fis3 啟動(dòng)時(shí)也會(huì)有顯示「FIS」

添加這種大號(hào)「藝術(shù)字」可以達(dá)到「品牌露出」的效果,當(dāng)然,也是程序員特有「情趣」的體現(xiàn)。 ??

但它們的實(shí)現(xiàn)方式無(wú)外乎把編排好的 Logo 通過(guò) console.log 輸出。這種方式問(wèn)題在于它幾乎沒(méi)有任何復(fù)用能力,而且一些需要轉(zhuǎn)義的情況還會(huì)導(dǎo)致字符串的可維護(hù)性極差。因此,我花了一個(gè)周末的時(shí)候,實(shí)現(xiàn)了一個(gè)易用的、可復(fù)用的控制臺(tái)「藝術(shù)字」lib。這樣,下次有新的需求,只需要把正常的文本傳給它,它就可以幫你自動(dòng)編排與打印。

image

1. 目標(biāo)

正如上節(jié)所說(shuō),目前一般項(xiàng)目的做法都是自定寫一串特定的文本,例如 minos:

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

還有 fis3 這種由于需要添加轉(zhuǎn)義所以顯得凌亂不好維護(hù)的

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

這種些方式都是通過(guò)「硬編碼」來(lái)實(shí)現(xiàn)的,如果有了新項(xiàng)目或需求變動(dòng)還得重新編排調(diào)整。

因此,準(zhǔn)備實(shí)現(xiàn)一種能夠根據(jù)輸入的字符串進(jìn)行自動(dòng)排版展示的控制臺(tái)「藝術(shù)字」打印庫(kù),例如通過(guò) yo('yoo-hoo') 就會(huì)輸出:

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

下次如果文案改了,直接換下字符串參數(shù)就行 —— yo('new-one')

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

總結(jié)來(lái)說(shuō),就是實(shí)現(xiàn)一個(gè)通用的、可復(fù)用的控制臺(tái)「藝術(shù)字」打印功能。基于這個(gè)目標(biāo)開(kāi)發(fā)了 yoo-hoo 這個(gè)庫(kù)。

下面來(lái)說(shuō)說(shuō)大致怎么實(shí)現(xiàn)。

2. 如何實(shí)現(xiàn)

和其他字體顯示的需求類似,我們可以將功能抽象為三個(gè)部分:

  1. 字體庫(kù)的生成
  2. 字體的排版
  3. 字體的渲染

這里我們先說(shuō)一下字體的渲染。

2.1. 字體渲染

之所以先說(shuō)這部分,是因?yàn)樗鼤?huì)影響排版信息的輸出格式。

其實(shí)字體渲染這部分并沒(méi)有什么特別的,我們?cè)诳刂婆_(tái)這個(gè)環(huán)境,受限于 API,基本就是使用 console.log 來(lái)將內(nèi)容「渲染」到屏幕上。不過(guò),正是這里的「渲染」形式的限制,會(huì)倒推我們的排版方式。

我們知道,控制臺(tái)基本都是單行順序渲染的,大致就是「Z」字型。同時(shí),由于我們的「藝術(shù)字」會(huì)占據(jù)多行,所以最終的渲染不是按單個(gè)字順序渲染的,需要先排好版,然后按行來(lái)逐步渲染到屏幕上。

這有點(diǎn)像是咱們常見(jiàn)的打印機(jī)。如果你要打印一個(gè)蘋果,它會(huì)從上往下逐步打印出這個(gè)蘋果,而不是直接像蓋章那樣直接印刷一個(gè)蘋果。

下面我們會(huì)先介紹字體庫(kù)的生成,而不是緊接挨著的字體排版。因?yàn)榕虐媸且粋€(gè)承上啟下的過(guò)程,當(dāng)我們確定了上下游環(huán)節(jié),這塊的邏輯自然也就確定了。

2.2. 字體庫(kù)生成

當(dāng)我們想要實(shí)現(xiàn)可復(fù)用能力時(shí),因此我們需要找到或者抽象出系統(tǒng)內(nèi)邏輯上的最小可復(fù)用單元 —— 在這里顯然就是字符。簡(jiǎn)單來(lái)說(shuō),對(duì)于輸入字符串 JS 時(shí),如果我們能找到對(duì)應(yīng)的 J 和 S 的字符表示形式,輔以排版,理論上就有能力實(shí)現(xiàn)我們的目標(biāo)。這有點(diǎn)像是咱們老祖宗的活字印刷術(shù)。

所以在字體庫(kù)這里,我們會(huì)有一個(gè)字義與字型的映射。這個(gè)其實(shí)和咱們前端常見(jiàn)的字體文件內(nèi)格式的思想一樣,都需要有這么一個(gè)映射關(guān)系。

字型哪里來(lái)呢?好吧,我也是用了一個(gè)笨辦法 —— 自己「手繪」??。舉個(gè)例子,下面就是我「手繪」的 1:

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

繪制的過(guò)程是枯燥的,好再很多字型的局部是有一定復(fù)用的,簡(jiǎn)化了這項(xiàng)繁瑣的工作。當(dāng)然,這只是一次性的工作,一旦創(chuàng)建好一類「字體」,以后就不需要再重復(fù)這項(xiàng)工作了。

我把上面這個(gè)內(nèi)容存在一個(gè)單獨(dú)的文件中,目前直接以 .txt 為后綴,這就是我們的字體原始格式。之所以不放在 .js 中,是因?yàn)?JavaScript 中 \ 是想要轉(zhuǎn)義的,這樣文本的視覺(jué)和最后的呈現(xiàn)效果就不一致了,不利于調(diào)試和維護(hù)。

原始字體文件分為兩部分:

  • 上面第一行是字義,支持一個(gè)多個(gè)字義對(duì)應(yīng)一個(gè)圖形。例如 ·* 我使用了同一個(gè)圖形。多個(gè)字義間空格分割,不換行。
  • 除去第一行,剩下的內(nèi)容就是字型。

理論上,我們可以以這個(gè)原始字體文件來(lái)作為字體庫(kù)了,通過(guò) NodeJS 中的 fs 模塊讀取并解析文件內(nèi)容即可得到映射關(guān)系。

但我希望它也能在非 NodeJS 環(huán)境(例如瀏覽器)中使用,所以不能依賴 fs 模塊。這里做了一個(gè)原始文件的解析腳本,生成對(duì)應(yīng)的 JS 模塊。由于我們并不直接維護(hù)這些生成的 JS 模塊,所以它的可讀性不重要,可以設(shè)計(jì)數(shù)據(jù)格式的時(shí)候可以完全面向后續(xù)的排版流程。

首先實(shí)現(xiàn)一個(gè)簡(jiǎn)單的解析器來(lái)解析第一行的字義。這也類似一個(gè)詞法解析器,但由于語(yǔ)法規(guī)則極其弱智(簡(jiǎn)單),所以也就不用多說(shuō)了,大致如下:

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;
}

下面就是處理字型部分。之所以需要處理字型,是因?yàn)樯厦嫣岬降霓D(zhuǎn)義問(wèn)題。由于我們?cè)谠几袷街惺褂昧?\ 來(lái)進(jìn)行字型展示,而將其直接放入生成的 JS 文件中這個(gè) \ 就變?yōu)榱宿D(zhuǎn)義符,要想正常展示需要變?yōu)?\\。一種方式是正則匹配,將所有源文本中的 \ 替換為 \\ 再寫入。但我選擇了另一種方式。

將字符通過(guò) .charCodeAt 方法轉(zhuǎn)為 char code 存儲(chǔ),讀取字體信息時(shí)再通過(guò) String.fromCharCode 轉(zhuǎn)回來(lái)。原來(lái)的字符串變成了數(shù)字類型的數(shù)組,這樣就沒(méi)有特殊字符的問(wèn)題了。最后,通過(guò)拼接文本并生成 JS 文件來(lái)將原始的、利于人維護(hù)的字體文件,轉(zhuǎn)成了編譯 JS 工作的模塊。

const arrayToString = <T>(arr: T[]) => '[' + arr.map(d => `'$u0z1t8os'`).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 就是這個(gè)字型對(duì)應(yīng)的字義列表,codes 則是字型的 char code 數(shù)組,所有的字體都被放在一個(gè) JS 文件中。

這里提一下,第 3 行的 parsedFonts 就是遍歷所有原始字體文件解析到的內(nèi)容,因此得到這部分也是需要通過(guò) NodeJS 的 fs 模塊來(lái)遞歸讀取源文件目錄下的字體文件的。算是基操,就不用展開(kāi)了。

由于這部分是可以提前解析編譯的,一旦生成了 JS 模塊后就不會(huì)對(duì) NodeJS 運(yùn)行時(shí)有依賴,所以保證了其依然可以運(yùn)行在瀏覽器中。

2.3. 字體的排版

我們的字體格式確定了,目標(biāo)的渲染方式也確定了。最后就可以填充這部分的邏輯實(shí)現(xiàn)了。

具體排版上會(huì)遇到一些細(xì)節(jié)點(diǎn),例如不等高字體的空行填充、最大行寬的換行判斷(需要用戶執(zhí)行行寬),不過(guò)這些都是小點(diǎn),處理也不太復(fù)雜。這里可能介紹一下稍有特殊的一塊 —— 字間距調(diào)整。

我們知道,一些藝術(shù)字的傾斜程度可能很大,例如這個(gè)字符「1」:

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

如果按簡(jiǎn)單的矩形型包圍盒來(lái)分配空間,大概會(huì)是下面這樣:

image

前后兩個(gè)字體,即使設(shè)置為最小間距(0),仍然會(huì)距離很遠(yuǎn),這樣就破壞了一定的顯示效果。例如上圖中我兩個(gè)包圍盒間距其實(shí)只有 1,但看起來(lái)就很大。我們實(shí)際希望的可能是下面這樣:

image

間距為 1 時(shí),兩個(gè)字符「1」調(diào)整為在最近的地方間距為 1。如果要更寬的效果可以設(shè)置更多間距。這個(gè)處理起來(lái)主要就是需要算出最大的「擠壓空間」(即兩個(gè)盒子最大支持的交叉空間)。最開(kāi)始渲染的時(shí)候說(shuō)了,我們是按 console 出的行來(lái)存儲(chǔ)的與打印的,舉個(gè)例子,這個(gè)「1」高度為 8 ,所以渲染的時(shí)候就是一個(gè) 8 個(gè)元素的字符串?dāng)?shù)組:

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

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

?? 注意,為了便于讀者閱讀,上面的 lines 數(shù)組內(nèi)的字符串我沒(méi)有加上轉(zhuǎn)義,它是不合法的!只是為了展示起來(lái)更便于閱讀理解,實(shí)際中不能這么寫。

最大縮進(jìn)(縮進(jìn)這個(gè)詞不準(zhǔn)確,但希望大家能夠理解那個(gè)意思)的計(jì)算只需要知道之前的每個(gè) line 尾部對(duì)應(yīng)有多少空格,同時(shí)需要再其后新添加字符每個(gè) line 前面又分別有多少空格,綜合兩者,再遍歷所有的 line 取一個(gè)最小值即可:

// 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 方法返回的就是新字符需要向前縮進(jìn)(或者說(shuō)縮緊)的值。最后渲染的時(shí)候根據(jù)這個(gè)值來(lái)調(diào)整每行連接時(shí)添加的空格數(shù)即可。

捎帶一提,之前的字體格式 load 進(jìn)來(lái)會(huì)被轉(zhuǎn)換為類似字典的格式 —— 字義作為 key,字型等一系列屬性作為 value:

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

這樣遍于 split 完用戶傳入的字符串后,更簡(jiǎn)單的索引到對(duì)應(yīng)的字型和字體信息。

2.4. 其他

當(dāng)然,其他還會(huì)有一些工作,包括

  • 支持顏色
  • 支持返回排版完的 lines 讓用戶自己渲染
  • 支持用戶自定義調(diào)整字間距

這些目前實(shí)現(xiàn)上遇到的問(wèn)題不大,篇幅原因也就不說(shuō)了。具體的代碼可以在 Github 上看到。

3. 總結(jié)

實(shí)現(xiàn)可復(fù)用的控制臺(tái)“藝術(shù)字”功能,總的來(lái)說(shuō)并沒(méi)有太多復(fù)雜的點(diǎn),整體的流程模型就是

生成字體庫(kù) --> 字體排版 --> 渲染文本

這對(duì)于前端來(lái)說(shuō)應(yīng)該是非常好理解的。

做這個(gè)項(xiàng)目也確實(shí)是自己在工作中希望給一些庫(kù)加上這種 logo 或者 banner 展示,但每次重復(fù)枯燥的工作確實(shí)令人反感。所以想了下可行性之后就搞了 yoo-hoo 這么個(gè)小玩意兒,如果大家也遇到類似的問(wèn)題,希望能有所幫助。

npm i yoo-hoo

4. 最后

目前 yoo-hoo@1.0.x 內(nèi)置了一套 26 個(gè)字母(A-Z)、10 個(gè)數(shù)字(0-9)、· * - | 這些字符的字體庫(kù)。

考慮到單一的字型和有限的字體量肯定不能滿足所有需求,所以開(kāi)發(fā)時(shí)代碼結(jié)構(gòu)就留下了支持外部擴(kuò)展的模式。

后續(xù)可以把 2.2 節(jié)中的字體源文件解析工具獨(dú)立出來(lái),支持用戶「手繪」自己的字型,用工具生成對(duì)應(yīng)格式后,將字體的 JS 模塊傳入 yo 方法中作為擴(kuò)展字體加載。

字體源文件的「手繪」雖有成本,但所見(jiàn)即所得,編寫難度不大 ?? 同時(shí)也算是一勞永逸。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容