/** * PhoneImageEngine - 手机图片排版引擎 * * 将文章HTML内容自动分页并渲染为手机尺寸图片 * 依赖: jQuery, html2canvas (项目已有) * * 渲染宽度540px, html2canvas scale=2 输出1080px * 小红书: 540x720 (输出1080x1440) * 抖音: 540x960 (输出1080x1920) */ var PhoneImageEngine = (function () { // ===== 配置 ===== var config = { size: 'xiaohongshu', fontSize: 14, watermark: '', pageAlignments: {}, // key=页码(1-based), value='top'|'center' sizes: { xiaohongshu: { width: 540, height: 720 }, douyin: { width: 540, height: 960 } }, contentPadding: 20 }; // ===== 文章数据 ===== var postData = { id: 0, title: '', desc: '', coverText: '', // 封面文案 content_html: '', poster: '', author_name: '', create_time: '', category_name: '' }; // ===== 分页结果 ===== var pages = []; /** * 初始化引擎 * @param {Object} options - {postId, title, desc, coverText, contentHtml, poster, authorName, createTime, categoryName} * @param {Object} userConfig - {size, fontSize, watermark} */ function init(options, userConfig) { postData.id = options.postId || 0; postData.title = options.title || ''; postData.desc = options.desc || ''; postData.coverText = options.coverText || ''; 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); } } /** * 渲染排版预览 - 生成分页后的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)); // 两遍遍历: 现在知道了总页数, 给每页补上 N/M 页码 var totalPages = pages.length; for (var i = 0; i < pages.length; i++) { if (pages[i].type === 'content') { // 在页脚中添加 N/M 格式页码 pages[i].html = pages[i].html.replace( '' + pages[i].pageNum + '', '' + (i + 1) + '/' + totalPages + '' ); } } // 渲染到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 'hr': block.type = 'page-break'; block.html = ''; block.estimatedHeight = 0; break; 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.type === 'page-break') { // 结束当前页 if (currentPageBlocks.length > 0) { contentPages.push(generateContentPage( currentPageBlocks, pageNumber, sizeConfig, false )); currentPageBlocks = []; currentHeight = 0; pageNumber++; } continue; // 跳过这个 page-break 块本身 } // 超大块处理:单个块超过整页高度时需要拆分 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) + '
'; } // 封面文案(有封面图) if (postData.coverText) { html += '
' + escapeHtml(postData.coverText) + '
'; } 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) + '
'; } // 封面文案(无封面图) if (postData.coverText) { html += '
' + escapeHtml(postData.coverText) + '
'; } 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 alignment = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top'; var valignClass = alignment === 'center' ? ' valign-center' : ''; var html = '
'; // 页头(仅第一页内容页显示标题) if (pageNum === 1) { html += ''; } // 正文内容区 html += '
'; for (var i = 0; i < blocks.length; i++) { html += blocks[i].html; } html += '
'; // 页脚 - 占位,render() 中会替换页码 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渲染 ===== /** * 全尺寸平铺渲染到 #paginated-preview */ function renderToDOM(sizeConfig) { var $preview = $('#paginated-preview'); if (!$preview.length) return; $preview.empty(); for (var i = 0; i < pages.length; i++) { $preview.append(pages[i].html); } } // ===== 图片生成 ===== /** * 逐页截图生成图片(简化版:所有页面已在DOM中可见) * @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 = $('#paginated-preview .phone-image-page'); if ($pages.length === 0) { deferred.reject('没有可渲染的页面'); return deferred.promise(); } var index = 0; var total = $pages.length; function captureNext() { if (index >= total) { if (onProgress) onProgress(total, total, null); deferred.resolve(canvases); return; } 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) { 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 switchSize(name) { config.size = name; } // ===== 逐页对齐 ===== /** * 设置指定页的对齐方式 * @param {number} pageNum 页码(1-based) * @param {string} align 'top'|'center' */ function setPageAlignment(pageNum, align) { if (!config.pageAlignments) { config.pageAlignments = {}; } config.pageAlignments[pageNum] = align; } // ===== 工具方法 ===== /** * 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(); } // ===== 公开API ===== return { init: init, render: render, generateImages: generateImages, saveImages: saveImages, showGeneratedThumbnails: showGeneratedThumbnails, switchSize: switchSize, getConfig: getConfig, getPages: getPages, setPageAlignment: setPageAlignment }; })();