22, // 最小字号;仍排不下则生成失败 'font_size_min' => 7, // 画布固定高度(px),宽度随文本自适应 'canvas_h' => 36, // 左右留白(px) 'pad_x' => 3, // 单行文本最大内宽(px) 'max_inner_width' => 640, // 相对画布高度的上下余量(px),参与可绘字高上限 'text_vertical_margin' => 2, // 文字颜色 [R,G,B],各分量 0~255 'color_rgb' => [0, 0, 0], // 为 true 时输出 PNG 透明底(适配深色顶栏等);为 false 时用 bg_rgb 铺底 'bg_transparent' => true, // 背景色 [R,G,B](仅 bg_transparent 为 false 时生效) 'bg_rgb' => [255, 255, 255], // 字体粗细:thin/细、regular/常规,类库内映射为 public/static/common/typeface 下具体 TTF 'font_variant' => 'thin', // 参与缓存文件名(模板目录名等),不参与像素绘制 'theme' => '', ]; } /** * 生成或复用图片访问地址 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 文件不存在或大小为 0 时写入 public/download/sensitive_data;已存在则直接返回展示地址 * @param [string] $text [展示文本,最长 128 字符(UTF-8)] * @param [array] $params [与 DefaultStyle 键名一致可平铺或放在 params['style'];font_variant 取 thin/细、regular/常规;高级用法可传 font_candidates 覆盖] * @return [string] [成功返回带域名的图片 URL,失败返回空字符串] */ public static function ImageUrl($text, $params = []) { $text = is_string($text) ? trim($text) : ''; if($text === '') { return ''; } if(mb_strlen($text, 'UTF-8') > 128) { $text = mb_substr($text, 0, 128, 'UTF-8'); } $style = self::MergeStyle($params); $secret = MyC('common_data_encryption_secret', 'shopxo', true); $filename = md5($text.$secret.'|'.self::StyleCacheKey($style)).'.png'; $path = 'download'.DS.'sensitive_data'.DS; $dir_full = ROOT.'public'.DS.$path; $file_full = $dir_full.$filename; if(!file_exists($file_full) || @filesize($file_full) <= 0) { if(FileUtil::CreateDir($dir_full) !== true) { return ''; } $binary = self::PngBinaryWithStyle($text, $style); if($binary === null || @file_put_contents($file_full, $binary, LOCK_EX) === false) { return ''; } } return ResourcesService::AttachmentPathViewHandle(DS.$path.$filename); } /** * 文本渲染为 PNG 二进制(不落盘) * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 仅内存生成 PNG,不写文件;文本为空或超长返回 null * @param [string] $text [utf-8 文本,最长 128 字符] * @param [array] $params [同 ImageUrl,用于覆盖默认样式] * @return [string|null] [成功返回 PNG 二进制,失败返回 null] */ public static function PngBinary($text, $params = []) { $text = is_string($text) ? trim($text) : ''; if($text === '' || mb_strlen($text, 'UTF-8') > 128) { return null; } return self::PngBinaryWithStyle($text, self::MergeStyle($params)); } /** * 合并并校验绘制样式 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 以 DefaultStyle 为底,合并 params['style'] 与 params 顶层同名字段;font_variant 细/常规在合并末解析为 font_candidates * @param [array] $params [输入参数,见 ImageUrl] * @return [array] [合并后的样式数组] */ private static function MergeStyle($params) { $style = self::DefaultStyle(); if(!empty($params['style']) && is_array($params['style'])) { $style = array_merge($style, $params['style']); } foreach(array_keys(self::DefaultStyle()) as $k) { if(array_key_exists($k, $params)) { $style[$k] = $params[$k]; } } $style['font_size'] = max(1, (int) $style['font_size']); $style['font_size_min'] = max(1, (int) $style['font_size_min']); if($style['font_size_min'] > $style['font_size']) { $style['font_size_min'] = $style['font_size']; } $style['canvas_h'] = max(8, (int) $style['canvas_h']); $style['pad_x'] = max(0, (int) $style['pad_x']); $style['max_inner_width'] = max(16, (int) $style['max_inner_width']); $style['text_vertical_margin'] = max(0, (int) $style['text_vertical_margin']); $style['color_rgb'] = self::NormalizeRgb($style['color_rgb'], [0, 0, 0]); $style['bg_transparent'] = !empty($style['bg_transparent']); $style['bg_rgb'] = self::NormalizeRgb($style['bg_rgb'], [255, 255, 255]); $tag = isset($style['theme']) && is_string($style['theme']) ? $style['theme'] : ''; $tag = preg_replace('/[^a-zA-Z0-9_-]/', '', $tag); if(strlen($tag) > 48) { $tag = substr($tag, 0, 48); } $style['theme'] = $tag; self::FinalizeFontCandidates($style, $params); return $style; } /** * 显式 font_candidates 优先;否则按 font_variant(thin/细、regular/常规)解析为 TTF 文件名 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @param [array] $style [引用,合并中的样式] * @param [array] $params [原始入参,用于读取顶层 font_candidates] * @return [void] */ private static function FinalizeFontCandidates(array &$style, array $params) { $explicit = null; if(array_key_exists('font_candidates', $params) && is_array($params['font_candidates'])) { $explicit = $params['font_candidates']; } if($explicit === null && isset($style['font_candidates']) && is_array($style['font_candidates'])) { $explicit = $style['font_candidates']; } if($explicit !== null) { $explicit = array_values(array_filter($explicit, function ($v) { return is_string($v) && $v !== ''; })); } if(!empty($explicit)) { $style['font_candidates'] = $explicit; } else { $style['font_candidates'] = self::FontVariantToFilenames(isset($style['font_variant']) ? $style['font_variant'] : 'thin'); } unset($style['font_variant']); } /** * font_variant 与 typeface 下文件名映射(细/常规) * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @param [mixed] $variant [thin|细、regular|常规,其它按 thin] * @return [array] */ private static function FontVariantToFilenames($variant) { if(!is_string($variant)) { return ['Alibaba-PuHuiTi-Thin.ttf']; } $v = trim($variant); $ascii = strtolower($v); if($ascii === 'thin' || $v === '细') { return ['Alibaba-PuHuiTi-Thin.ttf']; } if($ascii === 'regular' || $v === '常规') { return ['Alibaba-PuHuiTi-Regular.ttf']; } return ['Alibaba-PuHuiTi-Thin.ttf']; } /** * 归一化 RGB 颜色 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 非数组或分量不足时使用 $fallback;各分量限制在 0~255 * @param [mixed] $rgb [颜色,形如 [R,G,B] 的索引数组] * @param [array] $fallback [回退 RGB,长度 3] * @return [array] [长度为 3 的整型 RGB] */ private static function NormalizeRgb($rgb, $fallback) { if(!is_array($rgb) || count($rgb) < 3) { return $fallback; } $out = []; for($i = 0; $i < 3; $i++) { $v = (int) $rgb[$i]; if($v < 0) { $v = 0; } if($v > 255) { $v = 255; } $out[] = $v; } return $out; } /** * 样式参与缓存文件名的稳定摘要 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 对样式数组递归 ksort 后 json 编码再 md5,保证相同样式得到相同缓存键 * @param [array] $style [已合并、已校验的样式] * @return [string] [32 位小写 md5 摘要] */ private static function StyleCacheKey($style) { $s = $style; self::KsortRecursive($s); return md5(json_encode($s, JSON_UNESCAPED_UNICODE)); } /** * 递归按键名排序数组 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 用于生成稳定的 StyleCacheKey;子数组同样递归排序 * @param [array] $arr [引用传入,原地排序] * @return [void] */ private static function KsortRecursive(&$arr) { if(!is_array($arr)) { return; } ksort($arr); foreach($arr as &$v) { if(is_array($v)) { self::KsortRecursive($v); } } } /** * 按已合并样式将文本绘制成 PNG 二进制 * @author Devil * @blog http://gong.gg/ * @version 1.0.0 * @datetime 2026-05-13 * @desc 在 public/static/common/typeface 下按 font_candidates 寻找首个可用 TTF;画布高度固定,宽度随文本;超长或过高时递减字号直至满足 max_inner_width 与 max_text_h * @param [string] $text [utf-8 文本] * @param [array] $style [已 MergeStyle 的样式] * @return [string|null] [PNG 二进制,环境不支持 GD/字体或无法排下时返回 null] */ private static function PngBinaryWithStyle($text, $style) { $font_dir = ROOT.'public'.DS.'static'.DS.'common'.DS.'typeface'.DS; $font_path = ''; foreach($style['font_candidates'] as $font_file) { $p = $font_dir.$font_file; if(file_exists($p)) { $font_path = $p; break; } } if(!function_exists('imagecreatetruecolor') || $font_path === '' || !function_exists('imagettfbbox') || !function_exists('imagettftext')) { return null; } $canvas_h = $style['canvas_h']; $pad_x = $style['pad_x']; $max_inner_width = $style['max_inner_width']; $max_text_h = $canvas_h - $style['text_vertical_margin']; if($max_text_h < 4) { $max_text_h = max(4, $canvas_h - 1); } $color_rgb = $style['color_rgb']; $bg_rgb = $style['bg_rgb']; $font_size = $style['font_size']; $font_size_min = $style['font_size_min']; while($font_size >= $font_size_min) { $box = imagettfbbox($font_size, 0, $font_path, $text); if($box === false) { return null; } $minx = min($box[0], $box[2], $box[4], $box[6]); $maxx = max($box[0], $box[2], $box[4], $box[6]); $miny = min($box[1], $box[3], $box[5], $box[7]); $maxy = max($box[1], $box[3], $box[5], $box[7]); $text_width = $maxx - $minx; $text_height = $maxy - $miny; if($text_width <= $max_inner_width && $text_height <= $max_text_h) { break; } $font_size--; } if($font_size < $font_size_min) { return null; } $box = imagettfbbox($font_size, 0, $font_path, $text); if($box === false) { return null; } $minx = min($box[0], $box[2], $box[4], $box[6]); $maxx = max($box[0], $box[2], $box[4], $box[6]); $miny = min($box[1], $box[3], $box[5], $box[7]); $maxy = max($box[1], $box[3], $box[5], $box[7]); $text_width = $maxx - $minx; $text_height = $maxy - $miny; $img_w = (int) ceil($text_width + $pad_x * 2); $img_h = (int) $canvas_h; $im = imagecreatetruecolor($img_w, $img_h); if($im === false) { return null; } if(!empty($style['bg_transparent'])) { imagesavealpha($im, true); imagealphablending($im, false); $bg = imagecolorallocatealpha($im, 0, 0, 0, 127); imagefilledrectangle($im, 0, 0, $img_w, $img_h, $bg); imagealphablending($im, true); } else { $bg = imagecolorallocate($im, $bg_rgb[0], $bg_rgb[1], $bg_rgb[2]); imagefilledrectangle($im, 0, 0, $img_w, $img_h, $bg); } $fg = imagecolorallocate($im, $color_rgb[0], $color_rgb[1], $color_rgb[2]); $x = (int) round($pad_x - $minx); $y = (int) round(($canvas_h - $text_height) / 2 - $miny); imagettftext($im, $font_size, 0, $x, $y, $fg, $font_path, $text); ob_start(); imagepng($im); $binary = ob_get_clean(); imagedestroy($im); return ($binary === false || $binary === '') ? null : $binary; } } ?>