/** * 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(/]*>[\s\S]*?<\/script>/gi, ''); html = html.replace(/]*>[\s\S]*?<\/style>/gi, ''); // 清除空段落 html = html.replace(/]*>\s*<\/p>/gi, ''); // 处理图片: 清除内联样式, 强制 max-width:100% html = html.replace(/]*?)>/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 ''; }); return html; } /** * 将HTML解析为块级元素数组 * 每个块: { type, html, estimatedHeight } */ function parseHtmlToBlocks(html) { var blocks = []; if (!html) return blocks; var $temp = $('
').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 + '

', 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 + '', 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 = '
'; if (hasCover) { html += ''; html += '
' + escapeHtml(postData.title) + '
'; if (postData.desc) { html += '
' + escapeHtml(postData.desc) + '
'; } html += '
'; 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 += '
'; } else { // 无封面图: 大标题+摘要+装饰线条排版 html += '
'; html += '
'; html += '
' + escapeHtml(postData.title) + '
'; html += '
'; if (postData.desc) { html += '
' + escapeHtml(postData.desc) + '
'; } html += '
'; 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 += '
'; html += '
'; html += '
'; } html += '
'; return { type: 'cover', html: html }; } /** * 生成内容页HTML */ function generateContentPage(blocks, pageNum, sizeConfig, isLast) { var html = '
'; // 页头(仅首页内容页显示标题) if (pageNum === 1) { html += ''; } // 正文内容区 html += '
'; for (var i = 0; i < blocks.length; i++) { html += blocks[i].html; } html += '
'; // 页脚 html += ''; html += '
'; return { type: 'content', html: html, pageNum: pageNum }; } /** * 生成尾页/总结页HTML */ function generateSummaryPage(sizeConfig, totalPages) { var html = '
'; html += '
感谢阅读
'; html += '
' + escapeHtml(postData.title) + '
'; if (postData.desc) { html += '
' + escapeHtml(postData.desc) + '
'; } html += ''; html += '
'; 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 = $('
' + '第' + (i + 1) + '页' + '' + (i + 1) + '' + '
'); $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 += '

' + optimizedContent.optimized_paragraphs[i] + '

'; } 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 }; })();