之前在使用一些開(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)編排與打印。
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è)部分:
- 字體庫(kù)的生成
- 字體的排版
- 字體的渲染
這里我們先說(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ì)是下面這樣:
前后兩個(gè)字體,即使設(shè)置為最小間距(0),仍然會(huì)距離很遠(yuǎn),這樣就破壞了一定的顯示效果。例如上圖中我兩個(gè)包圍盒間距其實(shí)只有 1,但看起來(lái)就很大。我們實(shí)際希望的可能是下面這樣:
間距為 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í)也算是一勞永逸。