Files
sbnews/server/app/common/service/ShortLinkQrCodeService.php
2026-06-11 12:15:29 +08:00

668 lines
24 KiB
PHP

<?php
namespace app\common\service;
class ShortLinkQrCodeService
{
private const DATA_CODEWORDS = [1 => 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 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $viewBox . ' ' . $viewBox . '" width="360" height="360" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="#fff"/><path d="' . $path . '" fill="#000"/></svg>';
}
}