1. 问题现场:imagestring 写中文为什么全是问号和方块
先还原一个很典型的场景。你手上有一张商品图或者活动海报,想用 PHP 的 GD 库在右下角压一行中文水印,比如「内部资料 请勿外传」。代码写得很顺:
$im = imagecreatefromjpeg('bg.jpg'); $black = imagecolorallocate($im, 0, 0, 0); imagestring($im, 5, 20, 20, '内部资料 请勿外传', $black); imagejpeg($im, 'out.jpg');打开out.jpg一看,中文位置要么是一串?????,要么是几个空心方块,英文数字却正常。这就是 PHP 文字水印中文乱码最经典的入口。
问题不在你的 UTF-8 声明,也不在文件保存编码。imagestring()这个函数从设计上就只认 GD 内置的拉丁字体(font 1 到 font 5),它逐字节把字符串映射到点阵字模,一个字节对应一个字形。中文在 UTF-8 下是 3 个字节,它会把「内」拆成三个独立字节去查表,查不到就画成问号或空白。所以你加header('Content-Type: image/jpeg')没用,iconv('GBK','UTF-8',$text)也没用——编码再对,函数本身不认识多字节字符。
真正能画中文的是ImageTTFText()(也叫imagettftext()),它走的是 FreeType 渲染,需要你提供一个真正的 TTF/OTF 字体文件,由字体文件里的字形表来匹配 Unicode 码点。换句话说:imagestring是「按字节查内置点阵」,ImageTTFText是「按字符查字体文件」。中文乱码的根因,90% 是前者被误用,剩下 10% 是字体路径或字符集没配对。
这篇就按排查顺序走一遍:先确认函数选错,再解决字体文件找不到、中文路径、UTF-8 转码、字号坐标这些坑,最后给一段可以直接复制运行的完整代码,生成一张带中文水印的图来验证。适合正在用 GD 做图片合成、又不想引入 Imagick 的 PHP 开发者。
2. 前置准备:TaoToken 接入与 GD/FreeType 环境确认
在动手改代码之前,先把两件事确认掉:一是 PHP 的 GD 是否带 FreeType 支持,二是如果你打算把「生成水印」这一步接到大模型工作流里(比如让模型生成水印文案再批量压图),需要一个稳定的模型调用入口。这里我用 TaoToken 来做模型侧的统一接入,官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api 。
先说环境。ImageTTFText依赖 FreeType 库,很多默认编译的 GD 是不带的。用一行命令确认:
php -r "print_r(gd_info());"输出里重点看两个键:
[FreeType Support] => 1 [FreeType Linkage] => with freetype如果FreeType Support是空或者 0,那ImageTTFText会直接报Call to undefined function imagettftext(),或者运行时报字体相关错误。这种情况需要重新编译 GD 加--with-freetype-dir,或者在 Debian/Ubuntu 上装php-gd并确认libfreetype6-dev存在。Docker 环境里常见的是docker-php-ext-configure gd --with-freetype。
再说模型侧。为什么水印场景会牵扯到模型?因为实际项目里水印文案往往不是写死的,可能是模型根据图片内容生成的短句、编号、版权声明。这时候你需要一个能稳定调用的 API。TaoToken 的接入方式很直接,拿到 Key 之后,Base URL 填https://taotoken.net/api,模型 ID 按你选的填。控制台里可以创建和管理 Key,地址是 https://taotoken.net/console ,Key 管理页在 https://taotoken.net/api-keys 。
如果你只是想先验证某个模型能不能生成合适的中文水印文案,可以直接在模型对话页试: https://taotoken.net/model-chat 。要长期跑批量压图 + 文案生成的流水线,用 Coding Plan 更省心: https://taotoken.net/coding-plan 。文档在 https://taotoken.net/doc 。
这里要强调一个配置三件套的概念,后面排查报错会反复用到:Base URL + API Key + Model ID,三者缺一不可,任何一个填错都会在请求阶段就失败,而不是等到画图阶段。把这三样先记在配置里,别硬编码进业务代码。
环境确认完之后,我们进入真正的配置修正环节。
3. 可复制配置:字体路径、UTF-8 转码与 ImageTTFText 参数
这一节是核心,直接给可复制的片段。先解决字体文件。ImageTTFText的第二个参数是字体路径,必须是服务器上真实存在的 TTF/OTF 文件,而且路径要用绝对路径或相对当前工作目录的正确相对路径。很多人写'fonts/simhei.ttf'结果报Could not find/open font,就是因为脚本的工作目录不是项目根。
推荐做法是用__DIR__拼绝对路径:
$fontFile = __DIR__ . '/fonts/NotoSansSC-Regular.otf'; if (!is_file($fontFile)) { throw new RuntimeException('字体文件不存在: ' . $fontFile); }字体选择上,中文建议用思源黑体(Noto Sans SC)、文泉驿微米黑、或者系统自带的simhei.ttf(Windows)、/usr/share/fonts/truetype/wqy/wqy-microhei.ttc(Linux)。注意.ttc是字体集合,ImageTTFText对 ttc 支持不稳定,优先用.ttf或.otf。
然后是字符集。ImageTTFText接收的字符串必须是UTF-8。如果你的文案来自数据库且库是 GBK,需要先转:
$text = '内部资料 请勿外传'; if (!mb_check_encoding($text, 'UTF-8')) { $text = mb_convert_encoding($text, 'UTF-8', 'GBK'); }注意这里用mb_convert_encoding而不是iconv,因为iconv遇到非法字节会截断,mb_convert_encoding更宽容。转完之后可以再校验一次:
if (!mb_check_encoding($text, 'UTF-8')) { throw new RuntimeException('文案不是合法 UTF-8'); }接下来是ImageTTFText的完整参数。它的签名是:
imagettftext( GdImage $image, float $size, // 字号,单位是点,不是像素 float $angle, // 角度,0 是水平 int $x, // 基线起点 X int $y, // 基线起点 Y int $color, string $font_filename, string $text, array $options = [] ): array关键点是$y是基线位置,不是文字顶部。如果你按imagestring的习惯把 y 设成 20,中文会有一部分跑到画布外面。正确做法是先用imagettfbbox量出文字包围盒:
$size = 24; $angle = 0; $bbox = imagettfbbox($size, $angle, $fontFile, $text); $textWidth = $bbox[2] - $bbox[0]; $textHeight = $bbox[1] - $bbox[7];然后根据画布宽高算右下角坐标:
$padding = 20; $x = imagesx($im) - $textWidth - $padding; $y = imagesy($im) - $padding;这样文字就贴在右下角,不会溢出。
如果你要把这套流程接到模型生成文案的链路上,配置可以写成一个 JSON,方便和 API 调用共用:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model_id": "你的模型ID", "font_file": "/var/www/app/fonts/NotoSansSC-Regular.otf", "font_size": 24, "text_color": [255, 255, 255], "padding": 20 }注意base_url这里不带任何查询参数,就是纯端点。Key 从环境变量读,别写进 JSON 提交到仓库。Model ID 按你实际选的填,三者对应关系别搞混。
配置齐了,下面跑一次验证请求。
4. 验证请求:生成带中文水印图片并检查结果
写一个完整的可运行脚本,从加载图片到输出水印图,每一步都带检查。
<?php // watermark.php $src = __DIR__ . '/bg.jpg'; $dst = __DIR__ . '/out.jpg'; $fontFile = __DIR__ . '/fonts/NotoSansSC-Regular.otf'; if (!is_file($src)) { exit("源图不存在: $src\n"); } if (!is_file($fontFile)) { exit("字体不存在: $fontFile\n"); } $im = imagecreatefromjpeg($src); if ($im === false) { exit("加载图片失败\n"); } $text = '内部资料 请勿外传'; if (!mb_check_encoding($text, 'UTF-8')) { $text = mb_convert_encoding($text, 'UTF-8', 'GBK'); } $size = 24; $angle = 0; $bbox = imagettfbbox($size, $angle, $fontFile, $text); $textWidth = $bbox[2] - $bbox[0]; $textHeight = $bbox[1] - $bbox[7]; $padding = 20; $x = imagesx($im) - $textWidth - $padding; $y = imagesy($im) - $padding; $white = imagecolorallocate($im, 255, 255, 255); $shadow = imagecolorallocate($im, 0, 0, 0); // 先画一层黑色阴影,偏移 1px,提升可读性 imagettftext($im, $size, $angle, $x + 1, $y + 1, $shadow, $fontFile, $text); // 再画白色主体 imagettftext($im, $size, $angle, $x, $y, $white, $fontFile, $text); imagejpeg($im, $dst, 90); imagedestroy($im); echo "生成成功: $dst\n"; echo "文字宽度: {$textWidth}px, 高度: {$textHeight}px\n";命令行跑:
php watermark.php预期输出:
生成成功: /var/www/app/out.jpg 文字宽度: 216px, 高度: 24px打开out.jpg,右下角应该出现清晰的白色中文「内部资料 请勿外传」,带一层黑色描边阴影。如果中文正常显示,说明函数、字体、编码三件事都对了。
再补一个验证字符集的技巧:故意传一个 GBK 字符串进去,看会不会乱码,以此确认你的转码逻辑生效:
$gbkText = mb_convert_encoding('测试水印', 'GBK', 'UTF-8'); // 不转码直接传给 imagettftext 会乱码 // 转码后正常 $utf8Text = mb_convert_encoding($gbkText, 'UTF-8', 'GBK');如果你把水印生成接到了模型链路,验证请求可以分两步:先调模型拿文案,再压图。模型调用用 curl:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "你的模型ID", "messages": [{"role": "user", "content": "生成一句8字以内的中文版权水印文案"}] }'拿到返回的文案后,塞进上面的$text变量即可。这样整条链路就通了:模型出文案 → PHP 转码 → ImageTTFText 渲染 → 输出图片。
5. 常见报错排查:401、字体路径、choices 解析与 OAuth
这一节按真实报错逐条对。先说画图侧的。
报错一:Warning: imagettftext(): Could not find/open font
这是字体路径问题。三种可能:路径写的是相对路径但工作目录不对;字体文件权限不足(PHP-FPM 用户读不到);字体是.ttc集合格式不被支持。解决:用__DIR__拼绝对路径,chmod 644字体文件,换成.ttf/.otf。可以用is_readable($fontFile)先判断。
报错二:中文显示成方块或问号,但英文正常
说明字体文件本身不含中文字形。你用的可能是只带拉丁字符的字体,比如某些精简版 Arial。换成思源黑体、文泉驿或系统黑体。验证方法:用fc-list :lang=zh列出系统里支持中文的字体。
报错三:Warning: imagettftext(): any2eucjp(): something went wrong
这是老版本 PHP 在非 UTF-8 输入下的报错。根因还是字符串不是 UTF-8。用mb_check_encoding卡一道,确保传入前是合法 UTF-8。
报错四:文字位置偏移,跑到画布外
$y是基线不是顶部,且imagettfbbox返回的坐标在不同角度下含义不同。角度为 0 时,bbox[7]是顶部相对基线的负值。用imagesy($im) - $padding作为基线 y,配合 bbox 算宽度,基本不会错。
再说模型链路侧的报错,这几个在接入时高频出现。
报错五:HTTP 401 Unauthorized
Key 没带、带错、或者带了多余空格。检查Authorization: Bearer $KEY格式,确认 Key 从 https://taotoken.net/api-keys 复制完整。注意别把 Key 写进前端代码。
报错六:local proxy failed或连接超时
这类通常是网络出口或本地代理配置问题。检查你的运行环境是否能直连https://taotoken.net/api,确认没有残留的HTTP_PROXY/HTTPS_PROXY环境变量指向失效地址。清掉这些变量再试。
报错七:reading choices相关解析失败
返回体里没有choices字段,通常是请求体格式不对,比如messages写成了字符串、model字段缺失。对照文档 https://taotoken.net/doc 检查 JSON 结构。解析时先判断isset($resp['choices'][0]['message']['content'])再取值,别直接下标。
报错八:OAuth 相关错误
如果你用的是需要 OAuth 授权的客户端(比如某些 CLI 工具),token 过期或 scope 不足会报这个。重新走一遍授权流程,确认回调地址和 scope 配置正确。用 API Key 直连的方式可以绕开这类问题。
排查顺序建议:先确认gd_info()里 FreeType 为 1,再确认字体文件可读且含中文,再确认字符串是 UTF-8,最后才怀疑模型链路。大部分乱码在前三步就解决了。
6. 把水印链路接进批量流程:模型对话、Coding Plan 与文档入口
单张图跑通之后,实际项目往往是批量。比如电商后台每天要给出库图压水印,文案由模型按品类生成。这时候把上面的脚本封装成函数,循环调用即可:
function addWatermark(string $src, string $dst, string $text, string $fontFile, int $size = 24): bool { $im = imagecreatefromjpeg($src); if ($im === false) return false; if (!mb_check_encoding($text, 'UTF-8')) { $text = mb_convert_encoding($text, 'UTF-8', 'GBK'); } $bbox = imagettfbbox($size, 0, $fontFile, $text); $w = $bbox[2] - $bbox[0]; $x = imagesx($im) - $w - 20; $y = imagesy($im) - 20; $white = imagecolorallocate($im, 255, 255, 255); $shadow = imagecolorallocate($im, 0, 0, 0); imagettftext($im, $size, 0, $x + 1, $y + 1, $shadow, $fontFile, $text); imagettftext($im, $size, 0, $x, $y, $white, $fontFile, $text); $ok = imagejpeg($im, $dst, 90); imagedestroy($im); return $ok; }批量场景下,模型文案生成和图片处理可以解耦:先用一个队列把文案批量生成好存起来,再让压图任务读文案。模型侧如果只是偶尔生成短文案,用模型对话页手动试几次就够: https://taotoken.net/model-chat 。如果要长期跑、每天几千张图配文案,用 Coding Plan 更合适,配额和稳定性都更可控: https://taotoken.net/coding-plan 。
接入细节和参数说明都在文档里,遇到不确定的字段先查文档再改代码: https://taotoken.net/doc 。Key 的创建和轮换在控制台完成: https://taotoken.net/console ,Key 列表页是 https://taotoken.net/api-keys 。如果你用 Claude Code 这类工具做开发辅助,对应的接入说明在 https://taotoken.net/claudecode 。
最后留一个我踩过的坑:字体文件别放在会被 Web 直接访问的目录,否则别人能直接下载你的字体;同时 PHP-FPM 的open_basedir如果限制了目录,__DIR__拼出来的路径也可能被拦,报错同样是Could not find/open font,这时候要看 PHP 错误日志而不是只盯代码。把字体放到项目内一个受控目录,权限设成 PHP 运行用户可读,基本就稳了。