19, 2 => 34, 3 => 55, 4 => 80, 5 => 108]; private const ECC_CODEWORDS = [1 => 7, 2 => 10, 3 => 15, 4 => 20, 5 => 26]; private const ALIGNMENT_POSITIONS = [1 => [], 2 => [6, 18], 3 => [6, 22], 4 => [6, 26], 5 => [6, 30]]; private static array $exp = []; private static array $log = []; public static function create(string $content, string $code, string $posterUri = '', array $textData = []): array { $matrix = self::encode($content); $svg = self::toSvg($matrix); $dirUri = 'uploads/qrcode/shortlink'; $dir = public_path() . $dirUri; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $svgUri = $dirUri . '/' . $code . '.svg'; $svgPath = public_path() . $svgUri; file_put_contents($svgPath, $svg); $qrUri = $svgUri; $posterCompositeUri = ''; if (function_exists('imagecreatetruecolor')) { $pngUri = $dirUri . '/' . $code . '.png'; self::toPng($matrix, public_path() . $pngUri); $qrUri = $pngUri; // 有海报底图时合成完整海报:背景 + 文字层 + 二维码白底 if ($posterUri !== '' && self::isLocalImage($posterUri)) { $posterPath = public_path() . ltrim($posterUri, '/'); if (is_file($posterPath)) { $compositeUri = $dirUri . '/' . $code . '_poster.png'; if (self::composePoster($posterPath, public_path() . $pngUri, public_path() . $compositeUri, $textData)) { $posterCompositeUri = $compositeUri; } } } } // uri 用于落库 qrcode 字段:合成海报存在则取合成图,否则取纯二维码 $uri = $posterCompositeUri !== '' ? $posterCompositeUri : $qrUri; return [ 'uri' => $uri, 'url' => FileService::getFileUrl($uri), 'qr_uri' => $qrUri, 'qr_url' => FileService::getFileUrl($qrUri), 'poster_uri' => $posterCompositeUri, 'poster_url' => $posterCompositeUri !== '' ? FileService::getFileUrl($posterCompositeUri) : '', 'data_url' => 'data:image/svg+xml;base64,' . base64_encode($svg), ]; } private static function isLocalImage(string $uri): bool { $ext = strtolower(pathinfo($uri, PATHINFO_EXTENSION)); return in_array($ext, ['jpg', 'jpeg', 'png'], true); } /** * 合成海报:背景图 + 文字层 + 二维码(带白底圆角) */ private static function composePoster(string $posterPath, string $qrPath, string $outPath, array $textData = []): bool { $ext = strtolower(pathinfo($posterPath, PATHINFO_EXTENSION)); $bg = match ($ext) { 'jpg', 'jpeg' => @imagecreatefromjpeg($posterPath), 'png' => @imagecreatefrompng($posterPath), default => false, }; if (!$bg) { return false; } $qr = @imagecreatefrompng($qrPath); if (!$qr) { imagedestroy($bg); return false; } $bgW = imagesx($bg); $bgH = imagesy($bg); // 二维码尺寸:背景宽度的 32%,最小 200,最大 360 $qrSize = (int) max(200, min(360, $bgW * 0.32)); $padding = 3; $boxSize = $qrSize + $padding * 2; // 右下角,距离右边和底部各 5% $margin = (int) ($bgW * 0.05); $boxX = (int) ($bgW - $boxSize - $margin); $boxY = (int) ($bgH - $boxSize - $margin); // 文字层:在二维码上方区域绘制 $font = self::resolveFont(); if ($font !== null && !empty($textData)) { self::drawTextLayer($bg, $font, $bgW, $boxY, $textData); } // 白色底 $white = imagecolorallocate($bg, 255, 255, 255); imagefilledrectangle($bg, $boxX, $boxY, $boxX + $boxSize, $boxY + $boxSize, $white); // 二维码缩放后贴到底框内 $qrW = imagesx($qr); $qrH = imagesy($qr); imagecopyresampled( $bg, $qr, $boxX + $padding, $boxY + $padding, 0, 0, $qrSize, $qrSize, $qrW, $qrH ); $ok = imagepng($bg, $outPath); imagedestroy($bg); imagedestroy($qr); return $ok; } /** * 探测可用的中文字体路径,找不到返回 null(此时文字层会被跳过) * 注意:不能落到不支持中文的字体(如 DejaVuSans),否则会渲染成豆腐块 */ private static function resolveFont(): ?string { $candidates = [ public_path() . 'resource/front/cjk.ttf', public_path() . 'resource/front/cjk.otf', public_path() . 'resource/front/cjk.ttc', ]; foreach ($candidates as $path) { if (is_file($path)) { return $path; } } return null; } /** * 在海报左上区域绘制文字:标题(加粗+4px)→ 分享内容(+4px) * $boundsBottom 为文字区域可使用的底部 y 坐标(即二维码框顶部) */ private static function drawTextLayer($img, string $font, int $bgW, int $boundsBottom, array $textData): void { $padding = (int) ($bgW * 0.06); $maxW = (int) ($bgW - $padding * 2); // 文字区域:全宽(左右留 padding) $cursorY = (int) ($bgW * 0.06); $textColor = imagecolorallocate($img, 255, 255, 255); $shadow = imagecolorallocatealpha($img, 0, 0, 0, 70); // 海报标题(居中显示) $posterTitle = (string) ($textData['poster_title'] ?? ''); if ($posterTitle !== '') { $size = (int) max(34, $bgW * 0.032 + 18); $cursorY = self::drawBoldCenteredLine($img, $font, $size, $textColor, $shadow, $posterTitle, $bgW, $cursorY); $cursorY += (int) max(70, $size * 2.0); } // 分享人 $nickname = (string) ($textData['sharer']['nickname'] ?? ''); if ($nickname !== '') { $size = (int) max(8, $bgW * 0.022 - 4); $cursorY = self::drawBoldLeftLine($img, $font, $size, $textColor, $shadow, '@' . $nickname, $padding, $cursorY); $cursorY += (int) max(70, $size * 2.4); } // 标题:左上角,加粗(双层绘制模拟),字体 +16px $title = (string) ($textData['title'] ?? ''); if ($title !== '') { $size = (int) max(32, $bgW * 0.028 + 18); $cursorY = self::drawBoldLeftWrappedLines($img, $font, $size, $textColor, $shadow, $title, $maxW, $padding, $cursorY, 3, $boundsBottom); $cursorY += (int) max(70, $size * 1.6); } // 分享内容:在标题下方,字体 +8px,加粗 $description = (string) ($textData['description'] ?? ''); if ($description !== '' && $cursorY + 60 < $boundsBottom) { $size = (int) max(13, $bgW * 0.02 + 2); self::drawBoldLeftWrappedLines($img, $font, $size, $textColor, $shadow, $description, $maxW, $padding, $cursorY, 4, $boundsBottom); } } /** * 左对齐单行文字绘制 */ private static function drawLeftLine($img, string $font, int $size, int $color, int $shadow, string $text, int $x, int $y): int { $baseline = $y + $size; imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text); imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text); return $baseline; } /** * 左对齐加粗单行文字(三层绘制模拟加粗) */ private static function drawBoldLeftLine($img, string $font, int $size, int $color, int $shadow, string $text, int $x, int $y): int { $baseline = $y + $size; imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text); imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text); imagettftext($img, $size, 0, $x + 1, $baseline, $color, $font, $text); imagettftext($img, $size, 0, $x + 2, $baseline, $color, $font, $text); return $baseline; } /** * 左对齐换行文字 */ private static function drawLeftWrappedLines($img, string $font, int $size, int $color, int $shadow, string $text, int $maxW, int $padding, int $y, int $maxLines, int $maxBottom): int { $lines = self::wrapByPixel($text, $font, $size, $maxW, $maxLines); $lineHeight = (int) ($size * 1.4); foreach ($lines as $line) { if ($y + $lineHeight > $maxBottom) { break; } $y = self::drawLeftLine($img, $font, $size, $color, $shadow, $line, $padding, $y); $y += (int) ($size * 0.4); } return $y; } /** * 左对齐加粗换行文字 */ private static function drawBoldLeftWrappedLines($img, string $font, int $size, int $color, int $shadow, string $text, int $maxW, int $padding, int $y, int $maxLines, int $maxBottom): int { $lines = self::wrapByPixel($text, $font, $size, $maxW, $maxLines); $lineHeight = (int) ($size * 1.6); foreach ($lines as $line) { if ($y + $lineHeight > $maxBottom) { break; } $y = self::drawBoldLeftLine($img, $font, $size, $color, $shadow, $line, $padding, $y); $y += (int) ($size * 1.6); } return $y; } private static function drawCenteredLine($img, string $font, int $size, int $color, int $shadow, string $text, int $bgW, int $y): int { $box = imagettfbbox($size, 0, $font, $text); $w = abs($box[2] - $box[0]); $x = (int) (($bgW - $w) / 2); $baseline = $y + $size; imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text); imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text); return $baseline; } /** * 居中加粗单行文字(三层绘制模拟加粗) */ private static function drawBoldCenteredLine($img, string $font, int $size, int $color, int $shadow, string $text, int $bgW, int $y): int { $box = imagettfbbox($size, 0, $font, $text); $w = abs($box[2] - $box[0]); $x = (int) (($bgW - $w) / 2); $baseline = $y + $size; imagettftext($img, $size, 0, $x + 2, $baseline + 2, $shadow, $font, $text); imagettftext($img, $size, 0, $x, $baseline, $color, $font, $text); imagettftext($img, $size, 0, $x + 1, $baseline, $color, $font, $text); imagettftext($img, $size, 0, $x + 2, $baseline, $color, $font, $text); return $baseline; } private static function drawWrappedLines($img, string $font, int $size, int $color, int $shadow, string $text, int $maxW, int $bgW, int $y, int $maxLines, int $maxBottom): int { $lines = self::wrapByPixel($text, $font, $size, $maxW, $maxLines); $lineHeight = (int) ($size * 1.4); foreach ($lines as $line) { if ($y + $lineHeight > $maxBottom) { break; } $y = self::drawCenteredLine($img, $font, $size, $color, $shadow, $line, $bgW, $y); $y += (int) ($size * 0.4); } return $y; } /** * 按像素宽度对文本断行,超过 $maxLines 时末尾追加省略号 */ private static function wrapByPixel(string $text, string $font, int $size, int $maxW, int $maxLines): array { $chars = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []; $lines = []; $current = ''; foreach ($chars as $ch) { $candidate = $current . $ch; $box = imagettfbbox($size, 0, $font, $candidate); $w = abs($box[2] - $box[0]); if ($w > $maxW) { if ($current !== '') { $lines[] = $current; } $current = $ch; if (count($lines) >= $maxLines) { $lastIdx = count($lines) - 1; $lines[$lastIdx] = mb_substr($lines[$lastIdx], 0, max(0, mb_strlen($lines[$lastIdx]) - 1)) . '…'; return $lines; } } else { $current = $candidate; } } if ($current !== '') { $lines[] = $current; } return array_slice($lines, 0, $maxLines); } private static function toPng(array $matrix, string $path): void { $quiet = 1; $scale = 10; $size = count($matrix); $imageSize = ($size + $quiet * 2) * $scale; $image = imagecreatetruecolor($imageSize, $imageSize); $white = imagecolorallocate($image, 255, 255, 255); $black = imagecolorallocate($image, 0, 0, 0); imagefill($image, 0, 0, $white); for ($y = 0; $y < $size; $y++) { for ($x = 0; $x < $size; $x++) { if ($matrix[$y][$x]) { imagefilledrectangle( $image, ($x + $quiet) * $scale, ($y + $quiet) * $scale, ($x + $quiet + 1) * $scale - 1, ($y + $quiet + 1) * $scale - 1, $black ); } } } imagepng($image, $path); imagedestroy($image); } public static function get(string $content, string $code): array { $pngUri = 'uploads/qrcode/shortlink/' . $code . '.png'; $svgUri = 'uploads/qrcode/shortlink/' . $code . '.svg'; if (is_file(public_path() . $pngUri)) { return [ 'uri' => $pngUri, 'url' => FileService::getFileUrl($pngUri), 'data_url' => '', ]; } if (is_file(public_path() . $svgUri)) { return [ 'uri' => $svgUri, 'url' => FileService::getFileUrl($svgUri), 'data_url' => '', ]; } return self::create($content, $code); } private static function encode(string $content): array { $version = self::getVersion(strlen($content)); $data = self::buildData($content, $version); $codewords = array_merge($data, self::reedSolomonRemainder($data, self::ECC_CODEWORDS[$version])); $size = 17 + 4 * $version; $modules = array_fill(0, $size, array_fill(0, $size, false)); $isFunction = array_fill(0, $size, array_fill(0, $size, false)); self::drawFunctionPatterns($modules, $isFunction, $version); self::drawCodewords($modules, $isFunction, $codewords); self::applyMask($modules, $isFunction); self::drawFormatBits($modules, $isFunction); return $modules; } private static function getVersion(int $length): int { foreach (self::DATA_CODEWORDS as $version => $capacity) { if ($length <= $capacity) { return $version; } } return 5; } private static function buildData(string $content, int $version): array { $bits = []; self::appendBits($bits, 0x4, 4); self::appendBits($bits, strlen($content), 8); foreach (unpack('C*', $content) as $byte) { self::appendBits($bits, $byte, 8); } $capacity = self::DATA_CODEWORDS[$version] * 8; self::appendBits($bits, 0, min(4, $capacity - count($bits))); while (count($bits) % 8 !== 0) { $bits[] = 0; } $data = self::bitsToBytes($bits); for ($pad = 0xec; count($data) < self::DATA_CODEWORDS[$version]; $pad ^= 0xfd) { $data[] = $pad; } return $data; } private static function appendBits(array &$bits, int $value, int $length): void { for ($i = $length - 1; $i >= 0; $i--) { $bits[] = ($value >> $i) & 1; } } private static function bitsToBytes(array $bits): array { $bytes = []; for ($i = 0; $i < count($bits); $i += 8) { $byte = 0; for ($j = 0; $j < 8; $j++) { $byte = ($byte << 1) | ($bits[$i + $j] ?? 0); } $bytes[] = $byte; } return $bytes; } private static function initGalois(): void { if (self::$exp) { return; } self::$exp = array_fill(0, 512, 0); self::$log = array_fill(0, 256, 0); $x = 1; for ($i = 0; $i < 255; $i++) { self::$exp[$i] = $x; self::$log[$x] = $i; $x <<= 1; if ($x & 0x100) { $x ^= 0x11d; } } for ($i = 255; $i < 512; $i++) { self::$exp[$i] = self::$exp[$i - 255]; } } private static function multiply(int $x, int $y): int { if ($x === 0 || $y === 0) { return 0; } self::initGalois(); return self::$exp[self::$log[$x] + self::$log[$y]]; } private static function reedSolomonDivisor(int $degree): array { self::initGalois(); $result = [1]; for ($i = 0; $i < $degree; $i++) { $next = array_fill(0, count($result) + 1, 0); foreach ($result as $j => $coefficient) { $next[$j] ^= self::multiply($coefficient, 1); $next[$j + 1] ^= self::multiply($coefficient, self::$exp[$i]); } $result = $next; } return $result; } private static function reedSolomonRemainder(array $data, int $degree): array { $divisor = self::reedSolomonDivisor($degree); $result = array_fill(0, $degree, 0); foreach ($data as $byte) { $factor = $byte ^ $result[0]; array_shift($result); $result[] = 0; for ($i = 0; $i < $degree; $i++) { $result[$i] ^= self::multiply($divisor[$i + 1], $factor); } } return $result; } private static function drawFunctionPatterns(array &$modules, array &$isFunction, int $version): void { $size = count($modules); self::drawFinderPattern($modules, $isFunction, 3, 3); self::drawFinderPattern($modules, $isFunction, $size - 4, 3); self::drawFinderPattern($modules, $isFunction, 3, $size - 4); for ($i = 8; $i < $size - 8; $i++) { self::setFunctionModule($modules, $isFunction, 6, $i, $i % 2 === 0); self::setFunctionModule($modules, $isFunction, $i, 6, $i % 2 === 0); } foreach (self::ALIGNMENT_POSITIONS[$version] as $x) { foreach (self::ALIGNMENT_POSITIONS[$version] as $y) { if (!$isFunction[$y][$x]) { self::drawAlignmentPattern($modules, $isFunction, $x, $y); } } } for ($i = 0; $i <= 8; $i++) { self::setFunctionModule($modules, $isFunction, 8, $i, false); self::setFunctionModule($modules, $isFunction, $i, 8, false); } for ($i = 0; $i < 8; $i++) { self::setFunctionModule($modules, $isFunction, $size - 1 - $i, 8, false); self::setFunctionModule($modules, $isFunction, 8, $size - 1 - $i, false); } self::setFunctionModule($modules, $isFunction, 8, $size - 8, true); } private static function drawFinderPattern(array &$modules, array &$isFunction, int $x, int $y): void { $size = count($modules); for ($dy = -4; $dy <= 4; $dy++) { for ($dx = -4; $dx <= 4; $dx++) { $xx = $x + $dx; $yy = $y + $dy; if ($xx < 0 || $xx >= $size || $yy < 0 || $yy >= $size) { continue; } $distance = max(abs($dx), abs($dy)); self::setFunctionModule($modules, $isFunction, $xx, $yy, $distance !== 2 && $distance !== 4); } } } private static function drawAlignmentPattern(array &$modules, array &$isFunction, int $x, int $y): void { for ($dy = -2; $dy <= 2; $dy++) { for ($dx = -2; $dx <= 2; $dx++) { self::setFunctionModule($modules, $isFunction, $x + $dx, $y + $dy, max(abs($dx), abs($dy)) !== 1); } } } private static function setFunctionModule(array &$modules, array &$isFunction, int $x, int $y, bool $dark): void { $modules[$y][$x] = $dark; $isFunction[$y][$x] = true; } private static function drawCodewords(array &$modules, array $isFunction, array $codewords): void { $size = count($modules); $bitIndex = 0; $totalBits = count($codewords) * 8; $upward = true; for ($right = $size - 1; $right >= 1; $right -= 2) { if ($right === 6) { $right--; } for ($vert = 0; $vert < $size; $vert++) { $y = $upward ? $size - 1 - $vert : $vert; for ($j = 0; $j < 2; $j++) { $x = $right - $j; if ($isFunction[$y][$x]) { continue; } $dark = false; if ($bitIndex < $totalBits) { $dark = (($codewords[intdiv($bitIndex, 8)] >> (7 - ($bitIndex % 8))) & 1) !== 0; } $modules[$y][$x] = $dark; $bitIndex++; } } $upward = !$upward; } } private static function applyMask(array &$modules, array $isFunction): void { $size = count($modules); for ($y = 0; $y < $size; $y++) { for ($x = 0; $x < $size; $x++) { if (!$isFunction[$y][$x] && (($x + $y) % 2 === 0)) { $modules[$y][$x] = !$modules[$y][$x]; } } } } private static function drawFormatBits(array &$modules, array &$isFunction): void { $size = count($modules); $bits = self::getFormatBits(); for ($i = 0; $i <= 5; $i++) { self::setFunctionModule($modules, $isFunction, 8, $i, self::getBit($bits, $i)); } self::setFunctionModule($modules, $isFunction, 8, 7, self::getBit($bits, 6)); self::setFunctionModule($modules, $isFunction, 8, 8, self::getBit($bits, 7)); self::setFunctionModule($modules, $isFunction, 7, 8, self::getBit($bits, 8)); for ($i = 9; $i < 15; $i++) { self::setFunctionModule($modules, $isFunction, 14 - $i, 8, self::getBit($bits, $i)); } for ($i = 0; $i < 8; $i++) { self::setFunctionModule($modules, $isFunction, $size - 1 - $i, 8, self::getBit($bits, $i)); } for ($i = 8; $i < 15; $i++) { self::setFunctionModule($modules, $isFunction, 8, $size - 15 + $i, self::getBit($bits, $i)); } self::setFunctionModule($modules, $isFunction, 8, $size - 8, true); } private static function getFormatBits(): int { $data = 1 << 3; $remainder = $data << 10; for ($i = 14; $i >= 10; $i--) { if ((($remainder >> $i) & 1) !== 0) { $remainder ^= 0x537 << ($i - 10); } } return (($data << 10) | $remainder) ^ 0x5412; } private static function getBit(int $value, int $index): bool { return (($value >> $index) & 1) !== 0; } private static function toSvg(array $matrix): string { $quiet = 1; $size = count($matrix); $viewBox = $size + $quiet * 2; $path = ''; for ($y = 0; $y < $size; $y++) { for ($x = 0; $x < $size; $x++) { if ($matrix[$y][$x]) { $path .= 'M' . ($x + $quiet) . ',' . ($y + $quiet) . 'h1v1h-1z'; } } } return ''; } }