☰
console.log 输出字符画与自定义图片:原理、实现与避坑
2026/10/1 1:18:01 网站建设 项目流程

1. 为什么要在 console.log 里画图——三种可控形态与选型思路

写代码这些年,console.log 大概是我用得最多的一个函数,没有之一。多数人的用法很朴素:console.log(obj)、console.log('走到这里了'),把控制台当成一个随手丢东西的回收站,看一眼就清掉。但 console.log 的能力其实远超这个印象——它能画图。用一堆等宽字符拼出一张像素化的头像,用 ANSI 色块在终端里排出渐变条,或者在浏览器 DevTools 里直接打印一张带圆角、带透明度的自定义图片,都是它干的活。我第一次看到别人在控制台里打出项目 logo 的时候,第一反应是"这不就是 ASCII Art 吗",第二反应是"这东西怎么能自己生成",于是就有了这一路的折腾记录。

先说清楚这件事的边界。所谓"用 console.log 输出特殊字符图案或自定义图片",本质上不是让控制台变成画布,而是利用控制台只认文本、但文本可以被赋予颜色和样式这个特性,把一张图片的信息压缩成文本流。字符图案这条路,是"降维"——把二维像素阵列降到一维字符序列;颜色和样式这条路,是"借道"——用终端或浏览器对转义序列、CSS 的支持,把文本重新渲染成视觉块。两条路的代价完全不同,适用的场景也完全不同。搞明白这一点,后面的所有参数选择都是顺理成章的,而不是从网上抄一段代码碰运气。

1.1 控制台输出的三种形态

我把这十几年见过、写过的做法归成三类,每一类的技术底座都不一样。

第一类是纯字符画。把图片的灰度信息映射到一组按"视觉密度"排序的字符上,比如@最黑、空格最白,最后得到一张用文字拼出来的图。它的优点是跨平台无敌——只要有等宽字体的地方就能显示,输出是一段普通字符串,可以随便打印、随便写进日志、随便塞进 README。缺点是只有黑白灰,细节靠脑补。

第二类是ANSI 彩色字符画。在字符画的基础上,给每个字符加上 24 位真彩色转义序列,输出到支持 ANSI 的终端里就是彩色的。优点是色彩还原度相当高,缺点同样明显:体积会翻好几倍,而且一旦输出被重定向到文件或者被 CI 日志系统捕获,满屏的\x1b[38;2;...会糊成一片乱码。

第三类是浏览器 DevTools 里的图片输出。它不是字符画,而是用%c占位符加一段 CSS,让 Chrome 这类浏览器的控制台把"一个带有背景图的空盒子"渲染出来,视觉效果几乎和真图一致,还能控制尺寸、圆角、透明度。代价是它只活在浏览器开发者工具里,Node 终端完全用不了。

1.2 什么时候该用哪一种:场景对照表

选型这事没有绝对答案,但有几个判据可以帮你三秒做决定。

场景推荐形态核心理由
项目启动时的 logo 打印纯字符画一次输出、跨平台、可以硬编码进脚本
CLI 工具的调试彩蛋ANSI 彩色字符画终端环境可控,颜色能加分
前端项目的控制台签名DevTools 图片输出浏览器里效果最好,一句话搞定
写进日志文件 / CI 输出纯字符画(关色)避免转义序列污染日志
终端里实时预览远程图片彩色 + 半块字符在体积和观感之间取平衡

我自己的习惯是:默认先做纯字符画,确认效果和尺寸都对了,再按需往上叠颜色。反过来先做彩色、再回头砍颜色,往往会把颜色处理的代码和采样逻辑耦合在一起,改起来非常难受。另外提醒一句,如果你的输出有可能被重定向(比如node cli.js > out.txt),记得用process.stdout.isTTY判断一下,非终端环境就把颜色全部关掉,这一步能省掉后来排查半天乱码的时间。

2. 字符画的核心原理:灰度映射、密度表与宽高比校正

很多人第一次自己动手做字符画,得到的图要么变形得认不出来,要么糊成一团灰。这两个问题的根源都不在代码写得对不对,而在两个参数:字符密度表的选取和宽高比的校正。把这两个点吃透,剩下的就是纯粹的工程细节。

2.1 从像素到字符:灰度采样与密度表怎么定

整个过程可以拆成四步:读图、缩放、转灰度、查表映射。读图和缩放是常规操作,真正决定观感的是后两步。

转灰度这件事,直觉上会觉得"把 RGB 平均一下不就行了",也就是(R+G+B)/3。但人眼对三种颜色的敏感度完全不同——对绿色最敏感,蓝色最不敏感。所以标准的做法是加权求和。常用的两组权重是 ITU-R BT.601 的0.299R + 0.587G + 0.114B和 BT.709 的0.2126R + 0.7152G + 0.0722B。屏幕内容按 709 算更贴近观感,老式视频内容用 601。实测下来两者的差异在字符画这种粗糙粒度上肉眼几乎看不出,但如果你做的是精细灰度图,用 709 会显得层次更顺一点。

真正的重头戏是密度表。所谓密度表,就是一串按"占据的墨水面积"从多到少排序的字符。最经典的两个极端是:

@%#*+=-:. (从最密到最疏,最后一个是空格)

或者反过来:

.:-=+*#%@ (从最疏到最密)

这两串看着差不多,实际排列顺序不一样,映射时的索引方向也相反,这是新手最容易踩的坑——方向搞反了,出来的图就是"底片"效果,白的地方黑、黑的地方白。我自己的建议是统一用"密度递减"的写法,也就是第一个字符最黑、最后一个字符是空格,因为这样亮度值越大、索引越大、字符越稀疏,逻辑上顺。

密度表的长度也有讲究。太短(比如只有#.两个字符)会让画面变成硬二值化,失去中间层次;太长(超过十几个字符)在终端里会因为字符之间视觉密度差异太小而显得脏。我一般用 10 个字符左右,比常见的 8 到 10 个稍微宽一点,中间调过渡会更自然。如果原图对比度很低,还可以考虑把亮度做一次直方图拉伸,把暗部压到 0、亮部拉到 1,再映射,出来的层次会好很多。

2.2 宽高比:字符画"被拉长"的根源与两种修正方式

几乎所有人第一次做出字符画都会遇到同一个问题:图被纵向拉长了,像被人捏着两头拽过。原因很简单,等宽字符不是正方形。以常见的 Consolas 为例,字符的宽度大约是字号的 0.55 倍,而默认行高大概是字号的 1.2 倍。也就是说,一个字符占据的视觉区域,宽高比接近 1:2,纵向是横向的两倍。

这意味着如果你把一张 100x100 的图直接映射成 100 列 x 100 行的字符,每一行的视觉高度加起来是实际需要的两倍,画面自然被拉长。修正方式有两种:

第一种是把行数减半。目标列数定为cols,行数按rows = round(height / width * cols * 0.5)算。这里的0.5就是宽高比补偿系数。这个系数的取值其实是可调的,取决于你终端的具体字体和行高设置。我见过有人用 0.5,有人用 0.45,甚至有人用 0.55,最终效果差异肉眼可见。判断标准很朴素:拿一张圆形图案去跑,什么时候圆看起来是圆的,系数就对了。

第二种是把列数翻倍。行数按原比例算,列数乘以 2。这两种在数学上等价,但实际输出的字符数量差很多——列数翻倍意味着每一行字符更多,在终端宽度有限的情况下更容易触发自动换行,反而把画面搞乱。所以我更倾向第一种,用较少的总字符数达到同样的比例。

这里还有一个容易被忽略的细节:终端本身的宽高比。如果终端窗口特别窄或者特别宽,渲染出来的字符块形状会跟着变,比例补偿系数也应该跟着调。比较稳妥的做法是把系数做成命令行参数,默认 0.5,需要时手动微调,而不是写死在代码里。

2.3 缩放策略与细节取舍

图片缩放看着是小事,实际上直接决定字符画的"信息量"。字符画本质上是一次极大幅度的降采样:一张 1200x800 的图,最后可能只输出 100x33 个字符,也就是从 96 万像素降到 3300 个格子,压缩了将近 300 倍。这么大幅度的降采样,采样方式不同,结果会差很远。

最粗暴的是最近邻采样,直接取每个格子左上角的那个像素。速度快,但会丢掉大量细节,细线条会整段消失,噪点会被放大。稍微好一点的是区域平均,也就是把每个格子覆盖的所有像素取平均。这在数学上更合理,能保留整体亮度和结构,代价是计算量。我用的 jimp 这类库在resize时默认就是带插值的,缩小时实际效果接近区域平均,所以直接用库的 resize 就够了。

但插值也不是万能的。如果原图是线条画、二维码、或者本身分辨率就很低,插值会把锐利边缘抹成灰色过渡,字符画反而更糊。这种情况我一般先把图按整数倍缩小再插值,或者干脆关掉插值用最近邻,让边缘保持硬朗。判断方法很简单:先跑一次看看,如果主体轮廓是清楚的就保持,如果糊得认不出来就换采样方式。这个环节没有统一最优解,多试两次比看文档快。

3. 实操落地:Node.js 写一个图片转字符画并 console.log 输出

原理讲完了,接下来是能直接抄的部分。我选择 Node.js 做这条链路,理由很直接:console.log 本身就是 JS 的东西,整套流程放在一个运行时里,不用在 Python 转格式、Node 再读一遍。而且 Node 的终端能力封装得比较自然,process.stdout.columns这种信息直接就能拿到。

3.1 环境准备与依赖选型

图片解码这件事,不建议自己造轮子。PNG 有 zlib 压缩,JPEG 有哈夫曼编码,自己解一星期也未必对。现成的库主要有几个方向:sharp性能最强,底子是 libvips,但它是原生模块,安装时可能需要编译工具链;jimp是纯 JS 实现,装起来零门槛,性能差一些但对我们这种一次性小脚本完全够用;get-pixels更轻,但接口比较原始。

我选jimp,主要图它"装完就能跑"。

mkdir ascii-console && cd ascii-console npm init -y npm install jimp

如果你所在的网络环境装包慢,可以先配一个国内的镜像源再装,这一步对后续体验影响挺大的。装完之后建一个ascii.js,接下来所有代码都写在这里。

注意:Node 版本别太老。jimp的新版本要求 Node 16 以上,如果你机器上还是 12 或 14,要么升级 Node,要么锁定 jimp 的老版本,不然会在import处直接报错。

3.2 核心代码:读图、缩放、采样、映射

先给一个能跑的最小版本,把整条链路打通。代码我逐段注释了意图,方便你按需改。

const Jimp = require('jimp'); // 密度表:从左到右由密到疏,最后一个空格代表最亮 const RAMP = '@%#*+=-:. '; async function imageToAscii(filePath, cols = 100, aspect = 0.5) { const img = await Jimp.read(filePath); // 按原图比例算行数,再乘上宽高比补偿系数 const ratio = img.bitmap.height / img.bitmap.width; const rows = Math.max(1, Math.round(cols * ratio * aspect)); // 缩放到目标网格,jimp 默认插值,适合照片类素材 img.resize(cols, rows).grayscale(); const lines = []; for (let y = 0; y < rows; y++) { let line = ''; for (let x = 0; x < cols; x++) { const { r, g, b } = Jimp.intToRGBA(img.getPixelColor(x, y)); // BT.709 加权,比简单平均更接近人眼观感 const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; // 索引必须夹紧,lum 恰好为 1 时会越界 const idx = Math.min(RAMP.length - 1, Math.floor(lum * RAMP.length)); line += RAMP[idx]; } lines.push(line); } return lines.join('\n'); } imageToAscii(process.argv[2] || './avatar.png', 100) .then((art) => { console.log(art); }) .catch((err) => { console.error('转换失败:', err.message); });

跑起来就是node ascii.js ./test.png。如果一切正常,终端里会刷出一整屏字符画。到这里建议先拿一张对比度高、主体清晰的图做测试,比如白底黑字的 logo、纯色背景的人像。用风景照做第一次测试,大概率会因为细节太多而看不出效果,白白怀疑代码写错了。

几个参数的含义要交代清楚。cols是目标列数,不是越大越好。终端一般 80 到 120 列,超过这个数会触发自动换行,画面就散了。如果你想让输出自适应终端宽度,把cols的默认值改成:

const cols = Math.min(120, (process.stdout.columns || 80) - 1);

减 1 是为了防止最后一列刚好顶到边界触发换行,这个细节不注意的话会莫名其妙看到右侧多出一列文字。

aspect就是前面说的宽高比补偿系数,默认 0.5。如果你觉得图还是偏瘦或偏胖,直接命令行传参调,比如node ascii.js test.png 120 0.45。

还有一个映射方向的坑要强调:Math.floor(lum * RAMP.length)这行里,lum越大索引越大。因为RAMP是"由密到疏",索引越大对应字符越稀疏、视觉上越白,所以亮像素最终落在空格上,逻辑是自洽的。如果你把RAMP改成"由疏到密"的写法却没改索引方向,出来的就是黑白颠倒的负片。

3.3 彩色输出与参数微调

纯字符画跑通之后,如果想上颜色,最直接的做法是给每个字符套一个前景色转义序列。ANSI 24 位前景色的格式是\x1b[38;2;R;G;Bm,结尾用\x1b[0m重置。改起来就是在拼line时多加一段:

const color = `\x1b[38;2;${r};${g};${b}m`; line += color + RAMP[idx];

但这么写有个直接后果:每个字符都要带一段二十来个字节的转义序列,100 列 x 33 行就是 3300 个字符,光是颜色标记就接近 70KB。输出到终端还好,一旦重定向到文件就是一场灾难。所以我建议默认关闭颜色,只在你确认输出目标是终端的时候才开:

const useColor = process.stdout.isTTY === true;

如果确实想要彩色,又想压体积,有个很实用的技巧:用半块字符把两个像素叠进一个字符。Unicode 里的上半块字符▀(\u2580)在终端里显示时,上半部分是前景色、下半部分是背景色。这样一行字符就能表达两行像素,纵向分辨率直接翻倍,字符总数减半,体积也就跟着下来了。写法是:

const BLOCK = '\u2580'; // 上半个像素做前景,下半个像素做背景 const seq = `\x1b[38;2;${r1};${g1};${b1}m\x1b[48;2;${r2};${g2};${b2}m${BLOCK}`;

代价是每个字符要带两个颜色序列,单字符体积反而更大,但因为字符数量减半,总账还是划算的。另外这个做法对终端有要求,得支持 24 位真彩色,Windows 上建议用新版终端,老版控制台只支持 16 色,出来的颜色会明显失真。

实操心得:彩色字符画的调试成本比黑白高不少,建议先把黑白版的尺寸、比例、密度表都调到满意,再套颜色。颜色一旦叠上去,很容易掩盖掉采样和比例问题,让你误以为是颜色配置出了错。

4. 浏览器 DevTools 直接打印自定义图片的 %c 技巧

终端这条路走完,说浏览器端的。这一套和字符画完全是两套逻辑,不用做任何采样和映射,原理是利用 console 的样式占位符%c,让浏览器把一段文本按 CSS 渲染出来。本质上你是在 DevTools 里插了一个"带背景图的空盒子"。

4.1 %c 与 background-image 的原理拆解

%c是 console 支持的格式占位符之一,作用是把后面那个参数当成 CSS 规则应用。比如:

console.log('%cHello', 'color: red; font-size: 20px;');

就会打出一段红色大字。关键在于,样式可以作用在任意字符上,包括空格。既然空格也能被样式化,那我们就可以用padding把这个空格撑成一个指定宽高的盒子,再给它加background-image,这个盒子就变成了一张图。这就是整个技巧的全部秘密,一点也不玄学。

为什么必须用padding而不是width和height?因为 console 里的"字符"本质上是行内元素,行内元素对width/height的响应很差,但对padding完全支持。所以撑出尺寸这件事只能靠 padding,而且上下左右都要写,少一边盒子就是扁的。

还有两个参数必须处理:font-size: 0和line-height: 0。第一个是为了让那个空格本身不占据额外的字符宽度,第二个是防止默认行高把盒子纵向撑高、导致图片下面多出一段空白。这两个不写,图片会偏移,而且偏得莫名其妙。

4.2 参数计算与完整代码

给出一个完整可用的版本:

function logImage(url, size = 200) { const half = size / 2; const style = [ `background-image: url("${url}")`, 'background-size: 100% 100%', 'background-repeat: no-repeat', `padding: ${half}px ${half}px`, 'font-size: 0', 'line-height: 0', ].join(';'); console.log('%c ', style); } logImage('https://your-cdn.example.com/logo.png', 240);

把size设为 240,half就是 120,最后盒子是 240x240 的正方形。想做成非正方形,把宽高分开传即可,只要保证padding是宽高的一半。

几个我踩过的细节。第一,URL 必须带引号,尤其是带查询参数或特殊字符的地址,不加引号 CSS 解析会截断。第二,图片跨域要注意,DevTools 里背景图走的是普通 CSS 请求,如果对方服务器没放开跨域,图会加载失败,但控制台不会给你任何明显报错,只会显示一片空白,容易误以为是代码写错了。第三,DevTools 对单条 console 输出的长度是有限制的,超长的样式或超多张图可能被截断,一般单张图问题不大。

4.3 多图并排与动态更新

%c占位符可以写多个,对应的样式参数按顺序匹配。所以并排打印多张图很自然:

const s = (n) => `background-image:url("${n}");background-size:100% 100%;padding:60px 60px;font-size:0;line-height:0;`; console.log('%c %c %c ', s('/a.png'), s('/b.png'), s('/c.png'));

注意三个%c之间要用空格隔开,空格本身就是被样式化的那个"盒子",少了空格就没地方承载样式了。

至于动态更新,这个场景很多人问过。做法是不要重复console.log,而是先const el = console.log(...)拿到返回值——不行,console.log 不返回可引用的节点。正确的思路是用console.log打印一张,然后配合 DevTools 的实时刷新手动刷新,或者干脆用一个固定容器把背景图挂在页面 DOM 上。说实话,用 console 做实时动画不划算,控制台的刷新机制和帧率都不支持这种用法,真有动态需求应该去用页面上的 canvas。

提示:DevTools 里图片打印的效果和"控制台主题"有关。如果你用的是深色主题,透明背景的 PNG 会直接叠在深色底上,看起来会比在浅色主题下暗一档,这不是代码问题,换主题或者给图片加个浅色底就行。

5. 常见问题与排查技巧实录

这一节是我这些年真正花时间的地方。代码写出来只要几分钟,把各种环境下跑通、效果调对,才是大头。

5.1 变形、错位、糊成一团怎么办

画面被纵向拉长。这是最高频的问题,根源就是宽高比补偿没做或者系数不对。检查rows的计算里有没有乘aspect,然后拿一张圆形图实测,微调系数。别指望一次就调准,不同终端、不同字体、不同行高设置都会让系数漂移。

画面错位、右边多出一列。基本都是列数踩到了终端边界触发了自动换行。解决办法是把cols从process.stdout.columns里减掉 1 再 2,多留点余量。还有一个可能是你的字符串里混进了全角字符——密度表千万别用中文标点,全角字符占两个字符宽,一混进去整行就对不齐了。

整体糊成一团。三种可能:一是原图本身对比度低,灰度都挤在中间调,映射出来全是中间密度字符;二是缩放幅度太大,细节被丢光;三是密度表太短,层次不够。对应的解法分别是对原图做对比度拉伸、减小cols让它不要缩那么狠(是的,目标网格太小反而更糊)、把密度表拉长到 12 个以上。

颜色显示异常、一堆尖括号乱码。典型的转义序列没被识别。检查你输出的目标是不是真的终端,或者终端支不支持 24 位色。用process.stdout.isTTY做判断,非终端环境一律关色,这是最省事的兜底方案。

5.2 兼容性与环境问题速查表

现象常见原因处理方式
字符画高度正常但宽度错乱字体不是等宽换成 Consolas / 等宽字体
有一行字符跑到下一行列数超出终端宽度按stdout.columns动态计算
颜色全是方块或乱码终端不支持 24 位色降级为 16 色或关闭颜色
重定向到文件后满屏乱码转义序列被写进文件用isTTY判断后关色
DevTools 里图片不显示跨域被拦或 URL 没加引号换同域图片、补引号
打印图片下方多一条空白忘记line-height: 0补上该样式
Node 里 import jimp 报错模块规范不匹配用require或改成 ESM

这张表里的每一条我都至少踩过一次,尤其"重定向后满屏乱码"这条,当年往 CI 日志里塞彩色输出,排查了半个下午才反应过来是颜色没关。

5.3 性能与体积避坑

字符画的输出体积很容易被低估。100 列 x 33 行的黑白图,大概 3300 个字符,几 KB,完全没问题。但彩色版每个字符带一段二十多字节的转义序列,同样的尺寸就变成几十 KB;如果再用半块字符叠加前后景,单字符翻倍,总账可能上百 KB。往终端里打一次没事,如果这段代码在循环里被调用,或者在useEffect里反复触发,浏览器 DevTools 会明显卡顿,Node 的终端刷屏也会拖慢整个进程。

我的做法是三条:第一,输出前先算好尺寸,把cols上限定死,比如最大 120,不管终端多宽都不超;第二,彩色版本只在交互式终端启用,日志和重定向场景一律走黑白;第三,生成结果做一次缓存,同一张图同一个尺寸不要重复计算,尤其是在前端项目里,那点采样计算虽然不重,但在热更新频繁的开发环境里累积起来也不小。

实操心得:如果你的字符画是要放进项目启动脚本里当签名,建议提前生成好、以字符串常量硬编码进代码,而不是每次启动都读图重算。启动脚本追求的是快和稳,读文件、解码、采样这一套哪怕只要几十毫秒,也是白白浪费的,而且多一个依赖就多一个潜在的安装失败点。

6. 把字符画接进日常工具链的延伸玩法

上面这套东西跑通之后,我陆陆续续把它塞进了不少地方,这里挑几个自认为比较值当的说说,也算给这个思路留个出口。

一个是用在 CLI 工具的首次运行引导里。用户第一次跑你的命令行工具,打一段字符画 logo 加几行说明,比干巴巴的 help 文本有仪式感得多。这里的关键是一定要先判断是不是首次运行,用一个配置文件的标记位或者本地缓存目录里的一个空文件做判断,否则每次运行都刷一屏,第三次用户就会烦。同时记得判断isTTY,在 CI 里、在管道里就别打图案了,纯浪费。

另一个是用在构建产物的体积提示上。把打包结果按模块大小做归一化,映射成不同密度的字符,拼成一条横向的"体积热力条",直接打出来。这比一列数字直观得多,哪个模块胖了一眼就能看出来。这个用法里密度表不需要太长,五六个字符足够,重点是把差异拉开。

还有一个比较个人的用法,是给老照片做字符画再打印出来。这类场景对细节要求高,我的经验是先用图像工具把主体抠出来、背景统一成纯色,再丢进脚本,效果比直接跑原图好一大截。字符画的信息量本来就有限,让它专心表达主体,比什么都塞进去要明智。

最后说个我自己的体会。这套东西的技术栈其实很浅——一次灰度映射、一次比例换算,剩下的都是调参。但真正拉开差距的地方在于你有没有耐心把参数和环境这两件事分别对待。我见过太多代码逻辑写得漂漂亮亮、结果换个终端就全乱套的实现。字符画这类东西,能不能在自己机器上跑出来不重要,能不能在别人的机器上也跑出差不多的样子,才是判断它成熟与否的标准。所以我现在写这类脚本,第一件事不是写映射逻辑,而是先把isTTY判断、宽度自适应、颜色降级这三个兜底分支搭好,再往里面填核心算法。顺序反过来,后面返工的次数会多到你怀疑人生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询