使用方法
- 点「选择本地图片」挑一张图,PNG、JPEG、WebP、GIF、BMP 都能读,图片只在你自己的浏览器里解码。
- 顶部文字已预填 当你终于找到那个工具、底部预填 结果它把活干完了,改成你要的句子即可;每个框最多 80 个字素。
- 字号上限默认 9(本页示例值),只接受 4~20 的整数。它不是固定字号,而是天花板:画布宽 × 比例 ÷ 100,再由二分向下收缩到能排进 3 行、且最长一行不超过画布 90%。
- 描边宽度默认 6 px,允许 0~20 的整数,填 0 表示只填充不描边;文字色默认白色,描边色默认黑色。
- 导出格式选 PNG 或 JPEG;带透明背景的图必须用 PNG,选 JPEG 后透明区会变成黑色。
这个梗图生成器能解决什么问题
一是给照片配上下两行字,不装任何 App。手机拍的底图、群聊里的截图,拖进这个梗图生成器就能加字,桌面与移动浏览器同一个页面,也不用把照片传到别人服务器上。
二是把版式冻结下来批量出图。社群通知图、抽奖图、课件插图往往是一套固定版式只换文案:四个参数定好后,后面每张只改上下两行字,笔画粗细与留白比例完全一致。
三是中英混排与 emoji 不会被断坏。群里的文字常是「订单 1234 已发货 😂」这种混排,按字符数硬切的工具会把一个 ZWJ 家族 emoji 剪成两三段,导出后只剩几个残缺的小人头。本页用 Intl.Segmenter 按字素断行,把 👨👩👧👦 这种序列当成一个整体。
四是文字压在杂乱背景上仍看得清。照片亮一块暗一块,白字压在浅色天空上会直接消失;描边把每个字的外轮廓包一圈深色,比半透明色块更贴合字形。本页不做表情包模板搜索、GIF 动图、多图拼接与自动抠图。
渲染原理:measureText 换行、二分自适应与描边顺序
第一块基石是 CanvasRenderingContext2D.measureText()。它返回的 TextMetrics.width 是这段文字在当前 ctx.font 下的布局宽度——含字距推进量与两侧侧边距,不是墨迹包围盒。换行判断必须用它;用墨迹宽度会让相邻两行忽宽忽窄。
汉字有一个字体无关的性质:全角字的推进量等于 1 em,也就是 1 个字号。字号取 64 px 时每个汉字占 64 px,所以 1080 宽的画布留出 90%(972 px)后单行最多放 15 个汉字;96 px 只能放 10 个,120 px 只能放 8 个。拉丁字母平均推进量约 0.5 em。
换行分两级:先按空格切词,再按字素切字符。整段话先以空格切成词逐个往当前行里加,一旦 measureText(当前行 + 下一个词).width 超过 90% 上限就把当前行推入结果、另起一行。若某个词本身比一整行还长(英文长单词、连续数字,或压根没有空格的中文句子),再退到第二级:用 Intl.Segmenter 以 granularity: 'grapheme' 切成字素序列逐个塞。这保住 emoji 完整性——👨👩👧👦 在字素层面是 1 个单元,码点层面是 7 个,String.prototype.length 里却是 11 个 UTF-16 码元(4 个表情各 2 个,中间 3 个 ZWJ 各 1 个);按 .length 切会把它从中间剪开。
第二块基石是字号二分。搜索区间取 [12, 画布宽 × 比例 ÷ 100] 像素,每次取中点把文字包一遍,判断两个条件:行数是否不超过 3 行,最长一行宽度是否 ≤ 画布 90%。都满足就抬高下界,否则压低上界。固定迭代 20 次,区间宽 400 − 12 = 388 px,20 次后残留 388 ÷ 220 ≈ 0.00037 px,比一个屏幕像素小三千倍;再多迭代只浪费 measureText 调用。
描边顺序不能反:必须先 strokeText 再 fillText。lineWidth 以字形轮廓线为中心向两侧各扩张一半,6 px 的描边一半压在字形内部、一半露在外面。先描边后填充,填充把内侧那一半盖回去,笔画保持饱满,露在画面上的只有外面的 3 px。反过来先填充后描边,描边会压在笔画上,白字黑边比设定值细一圈。所以你填的 6 px 是 lineWidth 名义值,肉眼看到的轮廓只有一半宽。
描边宽度不能写死成固定像素。字号跟着画布走,描边也必须跟着:固定 6 px 在 1080 宽画布上占 0.556%,换到 3840 宽只剩 0.156%。本页按字号 × 0.125 给建议值(64 px 配 8 px、96 px 配 12 px),你仍可手工覆盖。
中文字体没加载完就测量,是换行错位的元凶。微软雅黑、苹方首次渲染前可能还在异步加载,此时 ctx.font 回退到默认 sans-serif,两种字体推进量表不同,measureText 量出的宽度偏大或偏小,换行点随之偏移,最坏时导出后文字溢出画布。正确做法是渲染前 await document.fonts.ready。
最后是导出格式。PNG 是 32 位带 alpha,JPEG 是 24 位无 alpha,同像素数下 JPEG 原始位图少 33.33%(1080×1080 从 4,665,600 字节降到 3,499,200 字节),代价是转成 JPEG 后所有透明像素被合成成黑色,所以带 alpha 的底图必须选 PNG。导出接口用 toBlob() 而非 toDataURL():后者同步执行,还要把二进制转成 base64,字符数比原二进制多 33.33%,一张 500000 字节的图会变成 666668 个字符,大图时足以卡住主线程。
完整示例(数值仅作演示)
打开页面时的默认条件:未选图片(预览用 1080×1080 内置底图)、顶部 10 个字素、底部 8 个字素、字号上限 9%、描边 6 px、导出 PNG。这只默认参数下的真实中间量如下:
- 画布 1080×1080,90% 文字区 = 1080 × 0.9 = 972 px;
- 字号上限 = 1080 × 9% = 97.2 px;
- 顶部「当你终于找到那个工具」是 10 个字素,10 × 97.2 = 972 px,正好等于 90% 上限,一行放下,所以二分停在上限 97.2 px;
- 底部「结果它把活干完了」是 8 个字素,8 × 97.2 = 777.6 px,比上限还空出 194.4 px;
- 1080×1080 的 RGBA 缓冲 = 1,166,400 像素 × 4 字节 = 4,665,600 字节 = 4.45 MiB;
- 另一个例子:字号上限调到 20%(216 px)、顶部换成 20 个汉字,二分会把实际字号压到 138.86 px,好让每行排 7 个字、共 3 行(7 × 138.86 ≈ 972);
- emoji 计数:
👨👩👧👦= 1 个字素 = 7 个码点 = 11 个 UTF-16 码元。
972 px 是 1080 的 90%,97.2 px 是它的 9%,两者相除正好是顶部那 10 个字——默认控件、示例与对照表三处咬合。
画布尺寸、字号与内存对照表
下表把常见画布边长换算成三个量:90% 文字区宽度、固定 64 px 字号下单行能排的汉字个数、整块画布的 RGBA 缓冲体积。RGBA 缓冲 = 边长 × 边长 × 4 字节,是画布在内存里的真实占用,和文件体积不是一回事。
| 画布边长 | 90% 文字区 | 64 px 单行汉字 | RGBA 缓冲 |
|---|---|---|---|
| 480×480 | 432 px | 6 个 | 0.88 MiB |
| 640×640 | 576 px | 9 个 | 1.56 MiB |
| 720×720 | 648 px | 10 个 | 1.98 MiB |
| 800×800 | 720 px | 11 个 | 2.44 MiB |
| 960×960 | 864 px | 13 个 | 3.52 MiB |
| 1024×1024 | 921.6 px | 14 个 | 4.00 MiB |
| 1080×1080(默认) | 972 px | 15 个 | 4.45 MiB |
| 1200×1200 | 1080 px | 16 个 | 5.49 MiB |
| 1440×1440 | 1296 px | 20 个 | 7.91 MiB |
| 1920×1920 | 1728 px | 27 个 | 14.06 MiB |
| 2560×2560 | 2304 px | 36 个 | 25.00 MiB |
| 3840×3840 | 3456 px | 54 个 | 56.25 MiB |
| 4096×4096 | 3686.4 px | 57 个 | 64.00 MiB |
| 5120×5120 | 4608 px | 72 个 | 100.00 MiB |
| 7680×7680 | 6912 px | 108 个 | 225.00 MiB |
| 8192×8192 | 7372.8 px | 115 个 | 256.00 MiB |
「加载更多尺寸」会补上 4096、5120、7680、8192 四档,其 RGBA 缓冲依次是 64.00、100.00、225.00、256.00 MiB,最后两档已接近单次分配上限。点「导出 CSV」可把整张表(含隐藏的 4 档)存成带 BOM 的 CSV,Excel 打开不乱码。
边界与精度说明
- 每个文本框最多 80 个字素。上限按字素而非 UTF-16 码元算:80 个汉字是 80 个字素,80 个
👨👩👧👦也只有 80 个字素、却有 880 个码元。超过会直接拦下并写明超了多少。 - 字号上限 4~20 的整数。4% 是给超长文案留的下限(1080 画布上只有 43.2 px);20% 再大就只能放三五个字。
- 描边宽度 0~20 px 的整数。填 0 表示纯填充不描边;名义值的一半才露在外面,20 px 已是相当粗的黑边。
- 单次渲染最多 3 行。超过 3 行时二分把字号压到 12 px 下限,仍排不下就明确报「文字太长」,而不是把字号缩到看不见。
- 画布边长上限 4096。Chromium 画布单边硬上限其实是 65535,但总面积一旦超过 228 = 268435456 像素,浏览器不报错而是静默给一张空白图。本页收在 4096,是为让 RGBA 缓冲不超过 64 MiB。
- 未选图片时预览的是内置底图,导出会被拦下。要导出必须点「载入示例底图」把它变成真正的位图,或选择自己的图片。
- JPEG 丢透明、PNG 保透明。带 alpha 的 PNG 底图导成 JPEG 后透明区被合成成黑色;需要压在另一种背景上复用请导 PNG。
- 放大不会增加细节。把 500 px 的底图拉到 1920 px,插值只能把像素抹平,锐度不会回来。
常见问题
图片会上传到服务器吗?
不会。文件通过 File 对象读进来,用 createImageBitmap 在本地解码,加字与编码都用 canvas 在你的浏览器里完成。想确认就打开开发者工具的网络面板,选图并导出,全程没有新请求。
为什么我填的字号是 9%,实际字号却不是画布宽的 9%?
因为这个 9% 是上限而非固定值。工具先按 9%(1080 画布即 97.2 px)试排,排进 3 行且最长一行不超过 90% 就采用上限,排不下才二分地往下压。想让字号尽量大就把比例往上调。
描边宽度填 6,为什么看起来只有 3 像素宽?
因为 lineWidth 以字形轮廓为中心向两侧各扩张一半,而本页先 strokeText 再 fillText,填充把压在字形内部的那一半盖了回去,露在画面上的只剩外侧一半。想要 6 px 可见外描边,名义值填 12。
我输入了 emoji,会被拆成两半吗?
不会。断行用 Intl.Segmenter 的字素粒度,一个 ZWJ 家族 emoji 无论由多少码点组成,在字素层面都算 1 个整体,要么整段留在本行、要么整段挪到下一行;字数上限也按字素算,一个这样的 emoji 只占 1 个位置。
顶部的字压在很亮的背景上看不清怎么办?
把描边宽度调大一点(比如 6 调到 12),或把描边色换成与背景反差更大的颜色。描边相当于给每个字加了一圈轮廓底,比半透明色条更贴合字形。背景太杂时,建议先用「模糊图片在线」压一下背景再回来加字。
导出的图为什么比原图还大?
因为导出走的是 PNG 无损编码,而很多照片原图是经过有损压缩的 JPEG,无损重建后的 PNG 自然更大。想控体积就改选 JPEG,或先用「图片尺寸调整」把底图缩到目标边长再来加字。
为什么竖着拍的照片导出来躺倒了?
竖拍照片的像素数据往往是横的,方向记录在 EXIF 的 Orientation 字段里。本页优先用 createImageBitmap(file, { imageOrientation: 'from-image' }),让解码器在解码阶段就把像素摆正,所以竖拍照片不会躺倒。
一次能处理多张吗?能加更多行文字吗?
都不能。这个梗图生成器刻意做成一次一张、上下各一行:大图编码是 CPU 密集操作,批量跑容易卡死标签页;而梗图的经典版式本就是顶底两句话。需要多行排版请用桌面端图像处理软件。