/** * 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 = []; /** * 确保隐藏渲染区域存在 */ function ensureStaging() { if ($('#render-staging').length === 0) { $('
').css({ position: 'fixed', left: '-9999px', top: 0, visibility: 'hidden' }).appendTo('body'); } } /** * 初始化引擎 * @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); } // 内容流事件委托(只绑定一次) $(document).off('click', '.break-inserter-btn'); $(document).off('click', '.remove-break-btn'); $(document).off('click', '.thumb-alignment-toggle'); $(document).on('click', '.break-inserter-btn', function () { var afterIndex = parseInt($(this).parent().data('after-index'), 10); insertPageBreak(afterIndex); }); $(document).on('click', '.remove-break-btn', function () { var index = parseInt($(this).data('index'), 10); removePageBreak(index); }); $(document).on('click', '.thumb-alignment-toggle', function () { var pageNum = parseInt($(this).data('page-num'), 10); var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top'; var newAlign = currentAlign === 'top' ? 'center' : 'top'; setPageAlignment(pageNum, newAlign); render(); }); } /** * 渲染排版预览 - 生成分页后的HTML * 两遍遍历: 先算总页数, 再带页码生成 * @returns {jQuery Deferred} resolves with pages array */ function render() { var deferred = $.Deferred(); 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 + '' ); } } // 渲染内容流(中间栏)- 同步 renderContentFlow(blocks); // 渲染缩略图(右侧)- 异步截图 renderThumbnails(sizeConfig).then(function () { deferred.resolve(pages); }).catch(function (err) { deferred.reject(err); }); return deferred.promise(); } /** * 预处理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 'table': block.type = 'table'; block.html = $el[0].outerHTML; block.estimatedHeight = estimateTableHeight($el); 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 estimateTableHeight($table) { var rows = $table.find('tr').length; return rows * 36 + 20; // 36px per row (35px content + 1px border) + 20px margin } // ===== 分页核心算法 ===== /** * 将块级元素按高度累加,超过可用高度时分页 * 支持
强制分页和超大块拆分(超长文本拆成多段) */ 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]; } // 表格块不拆分,保留原样 if (block.type === 'table') { 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渲染 ===== /** * 将 #render-staging 中的代码块转为图片 * 使用 prismjs 高亮 + html2canvas 截图替换 * @returns {jQuery Deferred} resolves when all code blocks are converted */ function convertCodeBlocks() { var deferred = $.Deferred(); var $staging = $('#render-staging'); // 检查 Prism 是否可用 if (typeof Prism === 'undefined') { deferred.resolve(); return deferred.promise(); } // 给没有语言类的代码块添加默认语言 $staging.find('pre > code:not([class*="language-"])').addClass('language-plaintext'); // 高亮所有代码块 $staging.find('pre code').each(function () { Prism.highlightElement(this); }); // 找到所有代码块,逐个截图替换 var $preBlocks = $staging.find('pre'); if ($preBlocks.length === 0) { deferred.resolve(); return deferred.promise(); } var idx = 0; var total = $preBlocks.length; function convertNext() { if (idx >= total) { deferred.resolve(); return; } var $pre = $preBlocks.eq(idx); html2canvas($pre[0], { scale: 2, useCORS: true, backgroundColor: '#f5f5f5', logging: false }).then(function (canvas) { var imgData = canvas.toDataURL('image/jpeg', 0.92); var $img = $(''); $img.attr('src', imgData); $img.css({ maxWidth: '100%', height: 'auto', display: 'block', margin: '10px 0' }); $pre.replaceWith($img); idx++; convertNext(); }).catch(function () { // 截图失败则保留原始代码块 idx++; convertNext(); }); } convertNext(); return deferred.promise(); } /** * 将 #render-staging 中的表格转为图片 * 使用 html2canvas 截图替换(与 convertCodeBlocks 模式相同) * @returns {jQuery Deferred} resolves when all tables are converted */ function convertTables() { var deferred = $.Deferred(); var $staging = $('#render-staging'); var $tables = $staging.find('.page-content table'); if ($tables.length === 0) { deferred.resolve(); return deferred.promise(); } var idx = 0; var total = $tables.length; function convertNext() { if (idx >= total) { deferred.resolve(); return; } var $table = $tables.eq(idx); html2canvas($table[0], { scale: 2, useCORS: true, backgroundColor: '#ffffff', logging: false }).then(function (canvas) { var imgData = canvas.toDataURL('image/jpeg', 0.92); var $img = $(''); $img.attr('src', imgData); $img.css({ maxWidth: '100%', height: 'auto', display: 'block', margin: '10px 0' }); $table.replaceWith($img); idx++; convertNext(); }).catch(function () { idx++; convertNext(); }); } convertNext(); return deferred.promise(); } // ===== 纯图片页优化 ===== /** * 检测staging区域中的页面元素是否为纯图片页 * 纯图片页: .page-body 内 .page-content 只含1个img且无文字内容 * @param {jQuery} $pageElem - .phone-image-page 元素 * @returns {boolean} */ function isPureImagePage($pageElem) { // 仅内容页 if (!$pageElem.hasClass('page-body')) return false; var $content = $pageElem.find('.page-content'); if (!$content.length) return false; // 检查是否有文字内容(排除纯空白文本节点) var textContent = $content.text().replace(/\s/g, ''); if (textContent.length > 0) return false; // 检查img数量: 必须恰好1个img var $imgs = $content.find('img'); if ($imgs.length !== 1) return false; // img必须有有效src var src = $imgs.first().attr('src'); return src && src.length > 0; } /** * 从纯图片页元素中获取img的src * @param {jQuery} $pageElem * @returns {string} */ function getPureImageSrc($pageElem) { var $img = $pageElem.find('.page-content img').first(); return $img.attr('src') || ''; } /** * 将原始图片绘制到canvas(用于高质量保存路径) * @param {string} src - 图片src * @param {number} width - 目标宽度 * @param {number} height - 目标高度 * @returns {jQuery Deferred} resolves with canvas */ function renderPureImageToCanvas(src, width, height) { var deferred = $.Deferred(); var img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function () { if (img.naturalWidth === 0 || img.naturalHeight === 0) { deferred.reject('Image has zero dimensions'); return; } var canvas = document.createElement('canvas'); canvas.width = width * 2; // scale:2 高质量 canvas.height = height * 2; var ctx = canvas.getContext('2d'); // 白色背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 缩放绘制 ctx.drawImage(img, 0, 0, canvas.width, canvas.height); deferred.resolve(canvas); }; img.onerror = function () { deferred.reject('Image load failed'); }; img.src = src; return deferred.promise(); } /** * 渲染到隐藏区域 → 截图 → 显示缩略图 * @param {Object} sizeConfig * @returns {jQuery Deferred} resolves with pages array */ function renderThumbnails(sizeConfig) { var deferred = $.Deferred(); ensureStaging(); var $staging = $('#render-staging'); $staging.empty(); // 渲染所有页面到隐藏区域(使用wrapper以便html2canvas正确渲染) var sizeClass = 'size-' + config.size; var $wrapper = $('
'); for (var i = 0; i < pages.length; i++) { $wrapper.append(pages[i].html); } $staging.append($wrapper); // 等待一帧确保 DOM 渲染完成 requestAnimationFrame(function () { // html2canvas会跳过visibility:hidden的元素,临时切换为可见 $staging.css({ visibility: 'visible' }); // 先转换代码块为图片 convertCodeBlocks().then(function () { return convertTables(); }).then(function () { var $pageElems = $staging.find('.phone-image-page'); if ($pageElems.length === 0) { $staging.css({ visibility: 'hidden' }); displayThumbnails([], sizeConfig); deferred.resolve(pages); return; } // 串行截图每一页 var thumbnailDataUrls = []; var idx = 0; var total = $pageElems.length; function captureNext() { if (idx >= total) { $staging.empty(); $staging.css({ visibility: 'hidden' }); displayThumbnails(thumbnailDataUrls, sizeConfig); deferred.resolve(pages); return; } var $elem = $pageElems.eq(idx); // 纯图片页优化: 跳过html2canvas,直接用原图src if (isPureImagePage($elem)) { var pureSrc = getPureImageSrc($elem); if (pureSrc) { thumbnailDataUrls.push(pureSrc); idx++; captureNext(); return; } } html2canvas($elem[0], { scale: 1, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).then(function (canvas) { thumbnailDataUrls.push(canvas.toDataURL('image/jpeg', 0.85)); idx++; captureNext(); }).catch(function (err) { $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err); }); } captureNext(); }).catch(function () { // 代码块转换失败,继续转换表格并截图流程 convertTables().then(function () { var $pageElems = $staging.find('.phone-image-page'); if ($pageElems.length === 0) { $staging.css({ visibility: 'hidden' }); displayThumbnails([], sizeConfig); deferred.resolve(pages); return; } var thumbnailDataUrls = []; var idx = 0; var total = $pageElems.length; function captureNext() { if (idx >= total) { $staging.empty(); $staging.css({ visibility: 'hidden' }); displayThumbnails(thumbnailDataUrls, sizeConfig); deferred.resolve(pages); return; } var $elem = $pageElems.eq(idx); // 纯图片页优化: 跳过html2canvas,直接用原图src if (isPureImagePage($elem)) { var pureSrc = getPureImageSrc($elem); if (pureSrc) { thumbnailDataUrls.push(pureSrc); idx++; captureNext(); return; } } html2canvas($elem[0], { scale: 1, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).then(function (canvas) { thumbnailDataUrls.push(canvas.toDataURL('image/jpeg', 0.85)); idx++; captureNext(); }).catch(function (err) { $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err); }); } captureNext(); }); }); }); return deferred.promise(); } /** * 在右侧显示缩略图 * @param {Array} dataUrls - base64 图片数据 * @param {Object} sizeConfig */ function displayThumbnails(dataUrls, sizeConfig) { var $preview = $('#paginated-preview'); if (!$preview.length) return; $preview.empty(); if (dataUrls.length === 0) return; // 计算缩放比例:缩略图高度 = 容器高度 - 40(页码标签) - 40(上下padding) var containerHeight = $preview.parent().height() || 700; var scaleRatio = (containerHeight - 80) / sizeConfig.height; var thumbWidth = Math.round(sizeConfig.width * scaleRatio); for (var i = 0; i < dataUrls.length; i++) { var $item = $('
'); $item.css('width', thumbWidth + 'px'); var $img = $(''); $img.attr('src', dataUrls[i]); $img.css('width', thumbWidth + 'px'); $item.append($img); // 页码 var $pageNum = $('' + (i + 1) + '/' + dataUrls.length + ''); $item.append($pageNum); // 对齐按钮(仅内容页) if (pages[i] && pages[i].type === 'content') { var pageNum = pages[i].pageNum || (i); var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top'; var isActiveCenter = currentAlign === 'center'; var $toggle = $(''); $item.append($toggle); } $preview.append($item); } } /** * 将页面渲染到隐藏区域并高质量截图(用于保存) * @returns {jQuery Deferred} resolves with canvas array */ function capturePagesFromStaging() { var deferred = $.Deferred(); ensureStaging(); var $staging = $('#render-staging'); $staging.empty(); var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; // 添加 size class wrapper var sizeClass = 'size-' + config.size; var $wrapper = $('
'); for (var i = 0; i < pages.length; i++) { $wrapper.append(pages[i].html); } $staging.append($wrapper); requestAnimationFrame(function () { // html2canvas会跳过visibility:hidden的元素,临时切换为可见 $staging.css({ visibility: 'visible' }); // 先转换代码块为图片 convertCodeBlocks().then(function () { return convertTables(); }).then(function () { var $pageElems = $staging.find('.phone-image-page'); if ($pageElems.length === 0) { $staging.css({ visibility: 'hidden' }); deferred.reject('没有可渲染的页面'); return; } var canvases = []; var idx = 0; var total = $pageElems.length; function captureNext() { if (idx >= total) { $staging.empty(); $staging.css({ visibility: 'hidden' }); deferred.resolve(canvases); return; } var $elem = $pageElems.eq(idx); // 纯图片页优化: 直接绘制原图到canvas if (isPureImagePage($elem)) { var pureSrc = getPureImageSrc($elem); if (pureSrc) { renderPureImageToCanvas(pureSrc, sizeConfig.width, sizeConfig.height).then(function (canvas) { canvases.push(canvas); idx++; captureNext(); }).catch(function () { // 加载失败,回退到html2canvas html2canvas($elem[0], { scale: 2, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).then(function (canvas) { canvases.push(canvas); idx++; captureNext(); }).catch(function (err) { $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err); }); }); return; } } html2canvas($elem[0], { scale: 2, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).then(function (canvas) { canvases.push(canvas); idx++; captureNext(); }).catch(function (err) { $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err); }); } captureNext(); }).catch(function () { // 代码块转换失败,继续转换表格并截图流程 convertTables().then(function () { var $pageElems = $staging.find('.phone-image-page'); if ($pageElems.length === 0) { $staging.css({ visibility: 'hidden' }); deferred.reject('没有可渲染的页面'); return; } var canvases = []; var idx = 0; var total = $pageElems.length; function captureNext() { if (idx >= total) { $staging.empty(); $staging.css({ visibility: 'hidden' }); deferred.resolve(canvases); return; } var $elem = $pageElems.eq(idx); // 纯图片页优化: 直接绘制原图到canvas if (isPureImagePage($elem)) { var pureSrc = getPureImageSrc($elem); if (pureSrc) { renderPureImageToCanvas(pureSrc, sizeConfig.width, sizeConfig.height).then(function (canvas) { canvases.push(canvas); idx++; captureNext(); }).catch(function () { // 加载失败,回退到html2canvas html2canvas($elem[0], { scale: 2, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).then(function (canvas) { canvases.push(canvas); idx++; captureNext(); }).catch(function (err) { $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err); }); }); return; } } html2canvas($elem[0], { scale: 2, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).then(function (canvas) { canvases.push(canvas); idx++; captureNext(); }).catch(function (err) { $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err); }); } captureNext(); }); }); }); return deferred.promise(); } /** * 渲染内容流到 #content-flow 容器 * @param {Array} blocks - parseHtmlToBlocks 返回的块数组 */ function renderContentFlow(blocks) { var $flow = $('#content-flow'); if (!$flow.length) return; $flow.empty(); for (var i = 0; i < blocks.length; i++) { var block = blocks[i]; // 分页标记 if (block.type === 'page-break') { var $marker = $('
' + '-- 分页标记 --' + '' + '
'); $flow.append($marker); continue; } // 内容块 var $block = $('
' + block.html + '
'); $flow.append($block); // 块与块之间的分页插入区域(不在最后一个块之后添加) if (i < blocks.length - 1) { var $inserter = $('
' + '' + '
'); $flow.append($inserter); } } } /** * 在指定位置插入分页标记 * 修改 postData.content_html,在对应位置插入
* @param {number} blockIndex - 在哪个块之后插入 (对应 break-inserter 的 data-after-index) */ function insertPageBreak(blockIndex) { var cleanHtml = preprocessContent(postData.content_html); var $temp = $('
').html(cleanHtml); var children = $temp.children(); // 在 blockIndex 位置的元素之后插入
if (blockIndex >= 0 && blockIndex < children.length) { $(children[blockIndex]).after('
'); } else { $temp.append('
'); } postData.content_html = $temp.html(); // 重新渲染 render(); } /** * 删除指定位置的分页标记 * @param {number} blockIndex - 分页标记的 data-index */ function removePageBreak(blockIndex) { // 统计这是第几个 page-break var cleanHtml = preprocessContent(postData.content_html); var $temp = $('
').html(cleanHtml); var hrElements = $temp.find('hr'); // 在 blocks 数组中找到这个 page-break 是第几个 var breakOrder = 0; var currentBlocks = parseHtmlToBlocks(cleanHtml); for (var i = 0; i < currentBlocks.length && i < blockIndex; i++) { if (currentBlocks[i].type === 'page-break') breakOrder++; } if (breakOrder < hrElements.length) { $(hrElements[breakOrder]).remove(); postData.content_html = $temp.html(); render(); } } // ===== 图片生成 ===== /** * 逐页截图生成图片(使用隐藏渲染区域高质量截图) * @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas) * @returns {Promise} jQuery Deferred, resolves with array of canvas objects */ function generateImages(onProgress) { var deferred = $.Deferred(); if (pages.length === 0) { deferred.reject('没有可渲染的页面'); return deferred.promise(); } if (onProgress) onProgress(0, pages.length, null); capturePagesFromStaging().then(function (canvases) { if (onProgress) onProgress(canvases.length, canvases.length, null); deferred.resolve(canvases); }).catch(function (err) { deferred.reject(err); }); 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(); capturePagesFromStaging().then(function (canvases) { if (onProgress) onProgress(canvases.length, canvases.length, null); 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, content_html: postData.content_html, 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; } /** * 导出内容流为长图 */ function exportLongImage() { var deferred = $.Deferred(); var $flow = $('#content-flow'); if (!$flow.length) { deferred.reject('内容流容器不存在'); return deferred.promise(); } // 临时隐藏交互元素 $flow.find('.break-inserter').hide(); $flow.find('.page-break-marker').hide(); // 获取内容流的实际高度 var flowWidth = $flow.outerWidth(); var flowHeight = $flow[0].scrollHeight; html2canvas($flow[0], { scale: 2, useCORS: true, backgroundColor: '#ffffff', width: flowWidth, height: flowHeight, logging: false }).then(function (canvas) { // 恢复交互元素 $flow.find('.break-inserter').show(); $flow.find('.page-break-marker').show(); // 触发下载 var link = document.createElement('a'); link.download = 'phone-image-long-' + Date.now() + '.png'; link.href = canvas.toDataURL('image/png'); link.click(); deferred.resolve(canvas); }).catch(function (err) { // 恢复交互元素 $flow.find('.break-inserter').show(); $flow.find('.page-break-marker').show(); deferred.reject('长图导出失败: ' + err); }); return deferred.promise(); } // ===== 工具方法 ===== /** * 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, renderContentFlow: renderContentFlow, insertPageBreak: insertPageBreak, removePageBreak: removePageBreak, exportLongImage: exportLongImage }; })();