Files
ulthon_information/public/static/js/phone-image.js
augushong 34fe255829 feat(phone-image): 增加翻页预览与无封面图排版样式
- 为手机截图生成器添加翻页功能,支持在生成前预览各页内容
- 增加无封面图时的排版样式,使用装饰线条和居中布局
- 改进图片处理逻辑,清除内联样式并展平嵌套包装元素
- 修复 models.dev 同步接口,支持 GET 请求获取缓存数据
- 优化网络请求,添加直连失败后的本地代理重试机制
2026-05-01 16:31:26 +08:00

878 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PhoneImageEngine - 手机图片排版引擎
*
* 将文章HTML内容自动分页并渲染为手机尺寸图片
* 依赖: jQuery, html2canvas (项目已有)
*
* 渲染宽度540px, html2canvas scale=2 输出1080px
* 小红书: 540x720 (输出1080x1440)
* 抖音: 540x960 (输出1080x1920)
*/
var PhoneImageEngine = (function () {
// ===== 配置 =====
var config = {
template: 'minimal',
size: 'xiaohongshu',
font: 'source-han-sans',
fontSize: 14,
watermark: '',
// 尺寸映射 (渲染尺寸, 实际输出是2倍)
sizes: {
xiaohongshu: { width: 540, height: 720 },
douyin: { width: 540, height: 960 }
},
// 字体映射
fonts: {
'source-han-sans': "'Source Han Sans', 'SourceHanSans-Normal', sans-serif",
'alibaba-puhuiti': "'AlibabaPuHuiTi', sans-serif",
'lxgw-wenkai': "'LXGWWenKai', cursive"
},
// 页面内容边距
contentPadding: 20
};
// ===== 文章数据 =====
var postData = {
id: 0,
title: '',
desc: '',
content_html: '',
poster: '',
author_name: '',
create_time: '',
category_name: ''
};
// ===== 分页结果 =====
var pages = [];
// ===== DOM引用 =====
var $container = null;
/**
* 初始化引擎
* @param {Object} options - {postId, title, desc, contentHtml, poster, authorName, createTime, categoryName}
* @param {Object} userConfig - {template, size, font, fontSize, watermark}
*/
function init(options, userConfig) {
postData.id = options.postId || 0;
postData.title = options.title || '';
postData.desc = options.desc || '';
postData.content_html = options.contentHtml || '';
postData.poster = options.poster || '';
postData.author_name = options.authorName || '';
postData.create_time = options.createTime || '';
postData.category_name = options.categoryName || '';
if (userConfig) {
$.extend(config, userConfig);
}
$container = $('#phone-image-container');
}
/**
* 渲染排版预览 - 生成分页后的HTML
* @returns {Array} 页面数组,每项是 { type, html, pageNum }
*/
function render() {
pages = [];
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
var pageHeight = sizeConfig.height;
var contentAreaHeight = pageHeight - (config.contentPadding * 2);
// 内容预处理
var cleanHtml = preprocessContent(postData.content_html);
// 解析为块级元素
var blocks = parseHtmlToBlocks(cleanHtml);
// 生成封面页
pages.push(generateCoverPage(sizeConfig));
// 内容分页
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
pages = pages.concat(contentPages);
// 生成尾页
pages.push(generateSummaryPage(sizeConfig, pages.length));
// 渲染到DOM
renderToDOM(sizeConfig);
return pages;
}
/**
* 预处理HTML内容
*/
function preprocessContent(html) {
if (!html) return '';
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 清除空段落
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
// 处理图片: 清除内联样式, 强制 max-width:100%
html = html.replace(/<img([^>]*?)>/gi, function (match, attrs) {
// 移除 style, width, height 内联属性
attrs = attrs.replace(/\s*style\s*=\s*"[^"]*"/gi, '');
attrs = attrs.replace(/\s*style\s*=\s*'[^']*'/gi, '');
attrs = attrs.replace(/\s*width\s*=\s*"[^"]*"/gi, '');
attrs = attrs.replace(/\s*width\s*=\s*'[^']*'/gi, '');
attrs = attrs.replace(/\s*height\s*=\s*"[^"]*"/gi, '');
attrs = attrs.replace(/\s*height\s*=\s*'[^']*'/gi, '');
return '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:10px 0;">';
});
return html;
}
/**
* 将HTML解析为块级元素数组
* 每个块: { type, html, estimatedHeight }
*/
function parseHtmlToBlocks(html) {
var blocks = [];
if (!html) return blocks;
var $temp = $('<div>').html(html);
// 展平嵌套的包装元素 (div, section, article, main, header, footer)
// 将其内部内容提取为直接子元素
var wrapperTags = ['div', 'section', 'article', 'main', 'header', 'footer', 'span'];
var changed = true;
var maxIterations = 10;
while (changed && maxIterations > 0) {
changed = false;
maxIterations--;
$temp.children().each(function () {
var $el = $(this);
var tagName = $el.prop('tagName').toLowerCase();
if ($.inArray(tagName, wrapperTags) !== -1) {
// 将此包装元素的子内容替换自身
var $children = $el.children();
if ($children.length > 0) {
$el.replaceWith($children);
changed = true;
}
}
});
}
var children = $temp.children();
if (children.length === 0) {
// 纯文本内容,按段落分割
var text = $temp.text().trim();
if (text) {
var paragraphs = text.split(/\n\s*\n|\n/);
for (var i = 0; i < paragraphs.length; i++) {
var p = paragraphs[i].trim();
if (p) {
blocks.push({
type: 'p',
html: '<p>' + p + '</p>',
estimatedHeight: estimateTextHeight(p, config.fontSize)
});
}
}
}
return blocks;
}
children.each(function () {
var $el = $(this);
var tagName = $el.prop('tagName').toLowerCase();
var block = {
type: tagName,
html: $el[0].outerHTML
};
switch (tagName) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
block.type = tagName;
block.estimatedHeight = estimateHeadingHeight(tagName, $el.text());
break;
case 'img':
block.estimatedHeight = estimateImageHeight($el);
block.$img = $el;
break;
case 'ul':
case 'ol':
block.estimatedHeight = estimateListHeight($el);
break;
case 'blockquote':
block.estimatedHeight = estimateBlockquoteHeight($el);
break;
case 'p':
default:
block.type = 'p';
block.estimatedHeight = estimateTextHeight($el.text(), config.fontSize);
break;
}
blocks.push(block);
});
return blocks;
}
// ===== 高度估算 =====
/**
* 获取内容区可用宽度 (540 - 40px padding)
*/
function getContentWidth() {
return 500;
}
/**
* 估算文本高度
* @param {string} text
* @param {number} fontSize
* @returns {number} px
*/
function estimateTextHeight(text, fontSize) {
var charCount = text.length;
if (charCount === 0) return 0;
var charsPerLine = Math.floor(getContentWidth() / (fontSize * 1.0));
if (charsPerLine < 1) charsPerLine = 1;
var lines = Math.ceil(charCount / charsPerLine);
return lines * fontSize * 1.8 + 10; // 1.8行高 + 10px段落间距
}
/**
* 估算标题高度
*/
function estimateHeadingHeight(tag, text) {
var sizeMap = { h1: 28, h2: 24, h3: 20, h4: 18, h5: 16, h6: 14 };
var hSize = sizeMap[tag] || 20;
var charsPerLine = Math.floor(getContentWidth() / (hSize * 1.0));
if (charsPerLine < 1) charsPerLine = 1;
var lines = Math.ceil(text.length / charsPerLine);
return lines * hSize * 1.4 + 20; // heading行高1.4 + 20px上下间距
}
/**
* 估算图片高度
* 默认按内容区宽度的60%估算
*/
function estimateImageHeight($img) {
var w = parseInt($img.attr('width'), 10);
var h = parseInt($img.attr('height'), 10);
if (w > 0 && h > 0) {
// 按比例缩放到内容宽度
var ratio = getContentWidth() / w;
return Math.round(h * ratio) + 10;
}
// 无尺寸信息时默认300px
return 310;
}
/**
* 估算列表高度
*/
function estimateListHeight($list) {
var items = $list.find('li').length;
return items * (config.fontSize * 1.8 + 5) + 15;
}
/**
* 估算引用块高度
*/
function estimateBlockquoteHeight($bq) {
var text = $bq.text();
return estimateTextHeight(text, config.fontSize) + 10;
}
// ===== 分页核心算法 =====
/**
* 将块级元素按高度累加,超过可用高度时分页
* 支持超大块拆分(超长文本拆成多段)
*/
function paginateContent(blocks, contentAreaHeight, sizeConfig) {
var contentPages = [];
var currentPageBlocks = [];
var currentHeight = 0;
var pageNumber = 1;
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i];
// 超大块处理:单个块超过整页高度时需要拆分
if (block.estimatedHeight > contentAreaHeight) {
// 先把当前页已有的内容推出去
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
// 尝试拆分超大块
var splitBlocks = splitOversizedBlock(block, contentAreaHeight);
for (var s = 0; s < splitBlocks.length; s++) {
var sb = splitBlocks[s];
if (currentHeight + sb.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
currentPageBlocks.push(sb);
currentHeight += sb.estimatedHeight;
}
continue;
}
// 正常块:判断是否需要换页
if (currentHeight + block.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
currentPageBlocks.push(block);
currentHeight += block.estimatedHeight;
}
// 最后一页
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, true
));
}
return contentPages;
}
/**
* 拆分超大块(超长段落文本拆成多段)
* @param {Object} block - 块级元素
* @param {number} pageHeight - 可用内容高度
* @returns {Array} 拆分后的块数组
*/
function splitOversizedBlock(block, pageHeight) {
// 图片块不拆分,保留原样(会被截断但保持完整)
if (block.type === 'img') {
return [block];
}
// 文本类块:按句子拆分
var text = '';
var wrapperTag = 'p';
if (block.type === 'blockquote') {
text = $(block.html).text();
wrapperTag = 'blockquote';
} else {
text = $(block.html).text();
}
if (!text || text.length === 0) {
return [block];
}
// 按句子拆分:句号、问号、叹号、换行
var sentences = text.split(/(?<=[。!?\n])/);
if (sentences.length <= 1) {
// 无法按句子拆分,按固定字符数拆分
sentences = [];
var chunkSize = Math.floor(getContentWidth() / config.fontSize) *
Math.floor(pageHeight / (config.fontSize * 1.8));
if (chunkSize < 10) chunkSize = 10;
for (var ci = 0; ci < text.length; ci += chunkSize) {
sentences.push(text.substring(ci, ci + chunkSize));
}
}
var result = [];
for (var j = 0; j < sentences.length; j++) {
var s = sentences[j].trim();
if (!s) continue;
var h = estimateTextHeight(s, config.fontSize);
result.push({
type: wrapperTag === 'blockquote' ? 'blockquote' : 'p',
html: '<' + wrapperTag + '>' + s + '</' + wrapperTag + '>',
estimatedHeight: h
});
}
return result.length > 0 ? result : [block];
}
// ===== 页面HTML生成 =====
/**
* 检查是否有有效的封面图(排除默认头像占位图)
*/
function hasValidPoster() {
if (!postData.poster) return false;
// 排除默认头像占位图
var defaultPatterns = ['/static/images/avatar.png', '/static/images/avatar.jpeg', '/static/images/avatar.jpg'];
for (var i = 0; i < defaultPatterns.length; i++) {
if (postData.poster.indexOf(defaultPatterns[i]) !== -1) return false;
}
return true;
}
/**
* 生成封面页HTML
*/
function generateCoverPage(sizeConfig) {
var hasCover = hasValidPoster();
var html = '<div class="phone-image-page page-cover';
if (hasCover) {
html += ' has-cover-image';
} else {
html += ' no-cover-image';
}
html += '" style="width:' + sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
if (hasCover) {
html += '<img class="cover-image" src="' + postData.poster + '" alt="">';
html += '<div class="cover-title">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="cover-meta">';
if (postData.category_name) {
html += escapeHtml(postData.category_name) + ' | ';
}
if (postData.author_name) {
html += escapeHtml(postData.author_name) + ' | ';
}
if (postData.create_time) {
html += postData.create_time;
}
html += '</div>';
} else {
// 无封面图: 大标题+摘要+装饰线条排版
html += '<div class="cover-decor-line cover-decor-top"></div>';
html += '<div class="cover-no-img-content">';
html += '<div class="cover-no-img-title">' + escapeHtml(postData.title) + '</div>';
html += '<div class="cover-decor-divider"></div>';
if (postData.desc) {
html += '<div class="cover-no-img-desc">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="cover-no-img-meta">';
if (postData.category_name) {
html += '<span>' + escapeHtml(postData.category_name) + '</span>';
}
if (postData.author_name) {
html += '<span>' + escapeHtml(postData.author_name) + '</span>';
}
if (postData.create_time) {
html += '<span>' + postData.create_time + '</span>';
}
html += '</div>';
html += '</div>';
html += '<div class="cover-decor-line cover-decor-bottom"></div>';
}
html += '</div>';
return { type: 'cover', html: html };
}
/**
* 生成内容页HTML
*/
function generateContentPage(blocks, pageNum, sizeConfig, isLast) {
var html = '<div class="phone-image-page page-body" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
// 页头(仅首页内容页显示标题)
if (pageNum === 1) {
html += '<div class="page-header">';
html += '<div class="page-title">' + escapeHtml(postData.title) + '</div>';
html += '</div>';
}
// 正文内容区
html += '<div class="page-content">';
for (var i = 0; i < blocks.length; i++) {
html += blocks[i].html;
}
html += '</div>';
// 页脚
html += '<div class="page-footer">';
html += '<span>' + escapeHtml(postData.author_name || '') + '</span>';
html += '<span>' + pageNum + '</span>';
html += '</div>';
html += '</div>';
return { type: 'content', html: html, pageNum: pageNum };
}
/**
* 生成尾页/总结页HTML
*/
function generateSummaryPage(sizeConfig, totalPages) {
var html = '<div class="phone-image-page page-summary" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
html += '<div class="summary-title">感谢阅读</div>';
html += '<div class="summary-text">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="summary-text" style="font-size:12px;">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="summary-footer">';
html += '共 ' + totalPages + ' 页';
if (postData.author_name) {
html += ' | ' + escapeHtml(postData.author_name);
}
html += '</div>';
html += '</div>';
return { type: 'summary', html: html };
}
// ===== DOM渲染 =====
// 当前预览页码 (0-based)
var currentPreviewPage = 0;
/**
* 渲染分页结果到DOM
*/
function renderToDOM(sizeConfig) {
if (!$container || !$container.length) return;
var containerClasses = [
'phone-image-container',
'tpl-' + config.template,
'size-' + config.size,
'font-' + config.font
];
$container.attr('class', containerClasses.join(' '));
$container.css({
'font-family': config.fonts[config.font] || config.fonts['source-han-sans'],
'font-size': config.fontSize + 'px'
});
$container.empty();
for (var i = 0; i < pages.length; i++) {
$container.append(pages[i].html);
}
// 确保当前页码有效
if (currentPreviewPage >= pages.length) {
currentPreviewPage = Math.max(0, pages.length - 1);
}
// 显示当前页, 隐藏其他页
showPreviewPage(currentPreviewPage);
}
/**
* 显示指定预览页
* @param {number} index 页码(0-based)
*/
function showPreviewPage(index) {
if (!$container || !$container.length) return;
var $allPages = $container.find('.phone-image-page');
$allPages.hide();
if (index >= 0 && index < $allPages.length) {
$allPages.eq(index).show();
currentPreviewPage = index;
}
// 更新页码显示
$container.trigger('pageChange', [currentPreviewPage, pages.length]);
}
/**
* 下一页
*/
function nextPage() {
if (currentPreviewPage < pages.length - 1) {
showPreviewPage(currentPreviewPage + 1);
}
}
/**
* 上一页
*/
function prevPage() {
if (currentPreviewPage > 0) {
showPreviewPage(currentPreviewPage - 1);
}
}
/**
* 获取当前预览页码
*/
function getCurrentPageIndex() {
return currentPreviewPage;
}
// ===== 图片生成 =====
/**
* 逐页截图生成图片
* @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas)
* @returns {Promise} jQuery Deferred, resolves with array of canvas objects
*/
function generateImages(onProgress) {
var deferred = $.Deferred();
var canvases = [];
var $pages = $container.find('.phone-image-page');
if ($pages.length === 0) {
deferred.reject('没有可渲染的页面');
return deferred.promise();
}
// 截图时需要所有页面都显示
$pages.show();
var index = 0;
var total = $pages.length;
function captureNext() {
if (index >= total) {
if (onProgress) onProgress(total, total, null);
// 恢复单页显示
showPreviewPage(currentPreviewPage);
deferred.resolve(canvases);
return;
}
// 隐藏其他页, 只显示当前要截图的页
$pages.hide();
$pages.eq(index).show();
html2canvas($pages.eq(index)[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: $pages.eq(index).outerWidth(),
height: $pages.eq(index).outerHeight(),
logging: false
}).then(function (canvas) {
canvases.push(canvas);
index++;
if (onProgress) onProgress(index, total, canvas);
captureNext();
}).catch(function (err) {
// 恢复单页显示
showPreviewPage(currentPreviewPage);
deferred.reject('截图失败(第' + (index + 1) + '页): ' + err);
});
}
if (onProgress) onProgress(0, total, null);
captureNext();
return deferred.promise();
}
/**
* 将canvas转为base64并保存到服务端
* @param {number} postId 文章ID
* @param {Object} saveConfig 配置信息
* @param {Function} [onProgress] 生成进度回调 function(current, total, canvas)
* @returns {Promise} jQuery Deferred
*/
function saveImages(postId, saveConfig, onProgress) {
var deferred = $.Deferred();
generateImages(onProgress).then(function (canvases) {
var pagesData = [];
for (var i = 0; i < canvases.length; i++) {
pagesData.push(canvases[i].toDataURL('image/jpeg', 0.92));
}
$.ajax({
url: '/index.php/admin/post/savePostOutput',
type: 'POST',
data: JSON.stringify({
post_id: postId,
output_type: 'phone_image',
config: saveConfig || config,
pages: pagesData
}),
contentType: 'application/json',
success: function (result) {
if (result.code === 0) {
deferred.resolve(result.data);
} else {
deferred.reject(result.msg || '保存失败');
}
},
error: function (xhr) {
deferred.reject('网络错误: ' + xhr.statusText);
}
});
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 将生成的canvas数组显示为缩略图
* @param {Array} canvases canvas对象数组
* @param {string} containerSelector 容器选择器
*/
function showGeneratedThumbnails(canvases, containerSelector) {
var $container = $(containerSelector);
$container.empty();
for (var i = 0; i < canvases.length; i++) {
var thumb = canvases[i].toDataURL('image/jpeg', 0.5);
var $item = $('<div class="phone-thumb-item" data-index="' + i + '">' +
'<img src="' + thumb + '" alt="第' + (i + 1) + '页">' +
'<span class="phone-thumb-page">' + (i + 1) + '</span>' +
'</div>');
$container.append($item);
}
}
// ===== 配置切换 =====
/**
* 切换模板
*/
function switchTemplate(name) {
config.template = name;
if ($container) {
$container.removeClass('tpl-minimal tpl-magazine tpl-mixed').addClass('tpl-' + name);
}
}
/**
* 切换尺寸
*/
function switchSize(name) {
config.size = name;
}
/**
* 切换字体
*/
function switchFont(name) {
config.font = name;
if ($container) {
$container.removeClass('font-source-han-sans font-alibaba-puhuiti font-lxgw-wenkai').addClass('font-' + name);
$container.css('font-family', config.fonts[name] || config.fonts['source-han-sans']);
}
}
// ===== 工具方法 =====
/**
* HTML转义
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
/**
* 获取当前配置
*/
function getConfig() {
return $.extend({}, config);
}
/**
* 获取分页结果
*/
function getPages() {
return pages.slice();
}
// ===== AI 智能排版 =====
/**
* 应用AI推荐配置
*/
function applyAiRecommendation(recommendation) {
if (recommendation.template) {
switchTemplate(recommendation.template);
$('.template-btn').removeClass('active');
$('.template-btn[data-template="' + recommendation.template + '"]').addClass('active');
$('[name="size"]').val(config.size);
}
if (recommendation.font) {
switchFont(recommendation.font);
$('[name="font"]').val(recommendation.font);
}
if (recommendation.font_size) {
config.fontSize = parseInt(recommendation.font_size);
$('[name="fontSize"]').val(config.fontSize);
$('#fontSizeValue').text(config.fontSize + 'px');
}
}
/**
* 应用AI优化内容替换预览用的内容
*/
var originalContentHtml = '';
function applyAiContent(optimizedContent) {
if (!originalContentHtml) {
originalContentHtml = postData.content_html;
}
if (optimizedContent.optimized_paragraphs) {
var newHtml = '';
for (var i = 0; i < optimizedContent.optimized_paragraphs.length; i++) {
newHtml += '<p>' + optimizedContent.optimized_paragraphs[i] + '</p>';
}
postData.content_html = newHtml;
postData.title = optimizedContent.optimized_title || postData.title;
}
}
/**
* 恢复原始内容
*/
function restoreOriginalContent() {
if (originalContentHtml) {
postData.content_html = originalContentHtml;
originalContentHtml = '';
}
}
// ===== 公开API =====
return {
init: init,
render: render,
paginate: render,
generateImages: generateImages,
saveImages: saveImages,
showGeneratedThumbnails: showGeneratedThumbnails,
switchTemplate: switchTemplate,
switchSize: switchSize,
switchFont: switchFont,
getConfig: getConfig,
getPages: getPages,
applyAiRecommendation: applyAiRecommendation,
applyAiContent: applyAiContent,
restoreOriginalContent: restoreOriginalContent,
nextPage: nextPage,
prevPage: prevPage,
showPreviewPage: showPreviewPage,
getCurrentPageIndex: getCurrentPageIndex
};
})();