/** * 操作日志面板模块 * 提供实时日志显示,替代 layer.load() 遮罩 */ var PhoneImageLogPanel = (function () { var $panel = null; var $body = null; var MAX_ENTRIES = 500; var entryCount = 0; function formatTime() { var d = new Date(); var h = (d.getHours() < 10 ? '0' : '') + d.getHours(); var m = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes(); var s = (d.getSeconds() < 10 ? '0' : '') + d.getSeconds(); return h + ':' + m + ':' + s; } function log(msg, level) { if (!$body) return; level = level || 'info'; var entryHtml = '
' + '' + formatTime() + '' + '' + msg + '' + '
'; $body.append(entryHtml); entryCount++; if (entryCount > MAX_ENTRIES) { $body.children().first().remove(); entryCount--; } $body.scrollTop($body[0].scrollHeight); } function clear() { if (!$body) return; $body.empty(); entryCount = 0; } function toggle() { if (!$panel) return; $panel.toggleClass('collapsed'); } function collapse() { if (!$panel) return; $panel.addClass('collapsed'); } function expand() { if (!$panel) return; $panel.removeClass('collapsed'); } function init(container) { var panelHtml = '
' + '
' + '操作日志' + '
' + '' + '' + '
' + '
' + '
' + '
'; $(container).append(panelHtml); $panel = $('#log-panel'); $body = $panel.find('.log-panel-body'); $('#log-btn-clear').on('click', function () { clear(); }); $('#log-btn-toggle').on('click', function () { toggle(); $(this).text($panel.hasClass('collapsed') ? '展开' : '收起'); }); } return { init: init, log: log, clear: clear, toggle: toggle, collapse: collapse, expand: expand }; })(); /** * PhoneImageEngine - 手机图片排版引擎 * * 将文章HTML内容自动分页并渲染为手机尺寸图片 * 依赖: jQuery, SnapDOM (项目已有) * * 渲染宽度540px, SnapDOM scale=2 输出1080px * 小红书: 540x720 (输出1080x1440) * 抖音: 540x960 (输出1080x1920) */ var PhoneImageEngine = (function () { // ===== 配置 ===== var config = { size: 'xiaohongshu', watermark: '', pageAlignments: {}, // key=页码(1-based), value='top'|'center' sizes: { xiaohongshu: { width: 540, height: 720 }, douyin: { width: 540, height: 960 } }, contentPadding: 20, fontScale: 1, tableFontScale: 1, codeFontScale: 1, useDomPreview: true }; // ===== DOM分页预览状态 ===== var domPages = []; var currentDomPageIndex = 0; var totalDomPages = 0; // ===== 文章数据 ===== var postData = { id: 0, title: '', desc: '', coverText: '', // 封面文案 content_html: '', poster: '', author_name: '', site_name: '', site_logo: '', 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 || ''; postData.site_name = options.siteName || ''; postData.site_logo = options.siteLogo || ''; if (userConfig) { $.extend(config, userConfig); } applyFontScale(); preloadBrandLogo(); } /** * 应用字号缩放到CSS变量 */ function applyFontScale() { var scale = config.fontScale || 1; var tableScale = config.tableFontScale || 1; var codeScale = config.codeFontScale || 1; var preview = document.getElementById('render-preview'); if (preview) { preview.style.setProperty('--pi-font-scale', scale); preview.style.setProperty('--pi-table-font-scale', tableScale); preview.style.setProperty('--pi-code-font-scale', codeScale); } var staging = document.getElementById('render-staging'); if (staging) { staging.style.setProperty('--pi-font-scale', scale); staging.style.setProperty('--pi-table-font-scale', tableScale); staging.style.setProperty('--pi-code-font-scale', codeScale); } } /** * 渲染排版预览 - 生成分页后的HTML * 管线: editorHtml -> parseHtmlToBlocks -> captureEditorBlocks -> generatePages -> renderThumbnails * 带并发锁:防止多次 render 同时执行造成数据混乱 * @returns {jQuery Deferred} resolves with pages array */ function render() { var deferred = $.Deferred(); // 防止并发渲染 if (render._locked) { // 标记有待处理的渲染请求,当前渲染完成后会自动重试 PhoneImageLogPanel.log('渲染进行中,跳过本次请求', 'warn'); render._pending = true; return deferred.reject('rendering').promise(); } render._locked = true; PhoneImageLogPanel.log('开始渲染...', 'info'); pages = []; var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; var pageHeight = sizeConfig.height; var contentAreaHeight = pageHeight - (config.contentPadding * 2); var brandHeaderOffset = (postData.site_name || postData.site_logo) ? BRAND_HEADER_HEIGHT : 0; contentAreaHeight -= brandHeaderOffset; // 从预览区读取已预处理的内容 syncPreview(); PhoneImageLogPanel.log('同步预览区内容', 'info'); var previewEl = document.getElementById('render-preview'); // 等待预览区内所有图片加载完成,否则 getBoundingClientRect 测高为0 PhoneImageLogPanel.log('等待图片加载...', 'info'); waitForImages(previewEl).then(async function () { // 预渲染超长代码块为图片 await preRenderCodeBlocks(contentAreaHeight); var cleanHtml = previewEl ? previewEl.innerHTML : ''; var blocks = parseHtmlToBlocks(cleanHtml); PhoneImageLogPanel.log('解析内容: ' + blocks.length + ' 个块', 'info'); // 空内容检测 if (blocks.length === 0) { $('#paginated-preview').html('
文章正文内容为空
'); render._locked = false; return deferred.resolve([]).promise(); } // 封面页 pages.push(generateCoverPage(sizeConfig)); PhoneImageLogPanel.log('生成封面页', 'info'); // 内容分页 PhoneImageLogPanel.log('分页计算中...', 'info'); captureEditorBlocks(cleanHtml, blocks, contentAreaHeight, sizeConfig).then(function(contentPages) { pages = pages.concat(contentPages); // 尾页 pages.push(generateSummaryPage(sizeConfig, pages.length)); PhoneImageLogPanel.log('生成尾页', 'info'); // 页码 N/M var totalPages = pages.length; for (var i = 0; i < pages.length; i++) { if (pages[i].type === 'content') { pages[i].html = pages[i].html.replace( '' + pages[i].pageNum + '', '' + (i + 1) + '/' + totalPages + '' ); } } // 渲染缩略图 if (config.useDomPreview) { renderDomPages(); PhoneImageLogPanel.log('DOM预览渲染完成,共 ' + pages.length + ' 页', 'success'); render._locked = false; if (render._pending) { render._pending = false; render().catch(function() {}); } deferred.resolve(pages); } else { renderThumbnails(sizeConfig).then(function() { PhoneImageLogPanel.log('渲染完成,共 ' + pages.length + ' 页', 'success'); render._locked = false; if (render._pending) { render._pending = false; render().catch(function() {}); } deferred.resolve(pages); }).catch(function(err) { PhoneImageLogPanel.log('渲染失败: ' + err, 'error'); render._locked = false; deferred.reject(err); }); } }).catch(function(err) { PhoneImageLogPanel.log('分页计算失败: ' + err, 'error'); render._locked = false; 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(//gi, '[媒体内容]'); html = html.replace(//gi, '[媒体内容]'); html = html.replace(//gi, ''); html = html.replace(/]*>/gi, ''); html = html.replace(//gi, ''); // 清除空段落 html = html.replace(/]*>\s*<\/p>/gi, ''); // 移除内联font-size,让CSS变量控制字号 html = html.replace(/\s*font-size\s*:\s*[^;'"<>]+;?/gi, ''); // 处理图片: 保留原始尺寸到data属性, 强制 max-width:100% html = html.replace(/]*?)>/gi, function (match, attrs) { // 移除内联style attrs = attrs.replace(/\s*style\s*=\s*"[^"]*"/gi, ''); attrs = attrs.replace(/\s*style\s*=\s*'[^']*'/gi, ''); // 提取原始width/height值, 保存到data属性 var widthVal = ''; var heightVal = ''; var widthMatch = attrs.match(/\swidth\s*=\s*"([^"]*)"/i) || attrs.match(/\swidth\s*=\s*'([^']*)'/i); var heightMatch = attrs.match(/\sheight\s*=\s*"([^"]*)"/i) || attrs.match(/\sheight\s*=\s*'([^']*)'/i); if (widthMatch) widthVal = widthMatch[1]; if (heightMatch) heightVal = heightMatch[1]; // 移除原始width/height属性 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, ''); // 添加data属性保存原始尺寸(用于比例计算) if (widthVal) attrs += ' data-original-width="' + widthVal + '"'; if (heightVal) attrs += ' data-original-height="' + heightVal + '"'; return ''; }); return html; } /** * 同步渲染预览区 - 将编辑器HTML写入预览区 */ function syncPreview() { var html = window.phoneImageEditor ? window.phoneImageEditor.getHtml() : postData.content_html; var cleanHtml = preprocessContent(html); $('#render-preview').html(cleanHtml); } /** * 等待容器内所有图片加载完成 * 图片未加载时 getBoundingClientRect() 高度为0,导致分页计算错误 * @param {HTMLElement} containerEl * @returns {jQuery Deferred} */ function waitForImages(containerEl) { var deferred = $.Deferred(); var imgs = containerEl ? containerEl.querySelectorAll('img') : []; if (imgs.length === 0) { return deferred.resolve().promise(); } var loaded = 0; var total = imgs.length; var timer = setTimeout(function () { // 超时保底:3秒后强制继续 deferred.resolve(); }, 3000); function checkDone() { loaded++; if (loaded >= total) { clearTimeout(timer); deferred.resolve(); } } for (var i = 0; i < imgs.length; i++) { if (imgs[i].complete && imgs[i].naturalHeight > 0) { checkDone(); } else { imgs[i].onload = checkDone; imgs[i].onerror = checkDone; } } return deferred.promise(); } /** * 将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: 40 }); } } } 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 = 40; break; case 'table': block.type = 'table'; block.html = $el[0].outerHTML; block.estimatedHeight = 0; block.needsMeasurement = true; break; case 'pre': block.type = 'pre'; block.html = $el[0].outerHTML; block.estimatedHeight = 0; block.needsMeasurement = true; break; case 'img': // 尝试从data属性计算比例高度, 否则标记需要DOM测量 var imgW = parseInt($el.attr('data-original-width'), 10); var imgH = parseInt($el.attr('data-original-height'), 10); if (imgW > 0 && imgH > 0) { var imgRatio = 500 / imgW; block.estimatedHeight = Math.round(imgH * imgRatio) + 20; } else { block.estimatedHeight = 0; block.needsMeasurement = true; } break; case 'ul': case 'ol': block.estimatedHeight = 40; break; case 'blockquote': block.estimatedHeight = 40; break; case 'p': default: block.type = 'p'; block.estimatedHeight = 40; break; } blocks.push(block); }); return blocks; } // ===== wangeditor 集成层 ===== /** * 获取 wangeditor 编辑区的子元素 * @returns {Array} DOM元素数组 */ function getEditorChildren() { var editorArea = document.querySelector('#editor-text-area [data-slate-editor]'); if (!editorArea) return []; var children = editorArea.children; // wangeditor v5: [data-slate-editor] 下可能有一层 div 包装 // 如果只有一个 child 且是 div,则取其子元素 if (children.length === 1 && children[0].tagName.toLowerCase() === 'div') { return Array.prototype.slice.call(children[0].children); } return Array.prototype.slice.call(children); } /** * 从 wangeditor 内容构建分页数据 * 完整实现:从编辑器DOM截图测高 -> 按分割线分组 -> 按高度分页 -> 生成staging HTML * @param {string} html - 编辑器HTML内容 * @param {Array} blocks - parseHtmlToBlocks 解析后的块数组 * @param {number} contentAreaHeight - 内容区可用高度(px) * @param {Object} sizeConfig - {width, height} * @returns {jQuery Deferred} resolves with contentPages array */ function captureEditorBlocks(html, blocks, contentAreaHeight, sizeConfig) { var deferred = $.Deferred(); // 按 page-break 分组 var groups = []; var currentGroup = { blocks: [], domIndices: [] }; var domIdx = 0; for (var i = 0; i < blocks.length; i++) { if (blocks[i].type === 'page-break') { if (currentGroup.blocks.length > 0) groups.push(currentGroup); currentGroup = { blocks: [], domIndices: [] }; domIdx++; continue; } currentGroup.blocks.push(blocks[i]); currentGroup.domIndices.push(domIdx); domIdx++; } if (currentGroup.blocks.length > 0) groups.push(currentGroup); if (groups.length === 0) { deferred.resolve([]); return deferred.promise(); } // 从 #render-preview 获取子元素(已预处理的干净内容) var previewEl = document.getElementById('render-preview'); if (!previewEl) { // fallback: 如果预览区不存在,给所有block设默认高度 for (var k = 0; k < blocks.length; k++) { if (!blocks[k].estimatedHeight) blocks[k].estimatedHeight = 40; } var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig); deferred.resolve(contentPages); return deferred.promise(); } var previewChildren = Array.prototype.slice.call(previewEl.children); // 逐 block 精确测高(包含 margin,否则分页偏小导致内容溢出) for (var gi = 0; gi < groups.length; gi++) { var group = groups[gi]; for (var j = 0; j < group.blocks.length; j++) { var di = group.domIndices[j]; if (di < previewChildren.length) { var el = previewChildren[di]; var rect = el.getBoundingClientRect(); var cs = window.getComputedStyle(el); var mt = parseFloat(cs.marginTop) || 0; var mb = parseFloat(cs.marginBottom) || 0; group.blocks[j].estimatedHeight = Math.round(rect.height + mt + mb); } } } // 给尚未设置高度的 block 设默认值 for (var k = 0; k < blocks.length; k++) { if (!blocks[k].estimatedHeight) { blocks[k].estimatedHeight = 40; } } var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig); deferred.resolve(contentPages); return deferred.promise(); } // ===== 分页核心算法 ===== /** * 将块级元素按高度累加,超过可用高度时分页 * 支持
强制分页和超大块拆分(超长文本拆成多段) */ function paginateContent(blocks, contentAreaHeight, sizeConfig) { var contentPages = []; var currentPageBlocks = []; var currentHeight = 0; var pageNumber = 1; var currentPageOverflowOffset = 0; // 续接页偏移量,0表示普通页 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, currentPageOverflowOffset )); currentPageBlocks = []; currentHeight = 0; currentPageOverflowOffset = 0; pageNumber++; } continue; // 跳过这个 page-break 块本身 } // 超大块处理:单个块超过整页高度时需要拆分 if (block.estimatedHeight > contentAreaHeight) { // 检查当前页最后一个块是否为标题,若是则回退使其与超大块第一部分同页 var lastBlockInPage = currentPageBlocks.length > 0 ? currentPageBlocks[currentPageBlocks.length - 1] : null; var lastBlockIsHeading = lastBlockInPage && (lastBlockInPage.type === 'h2' || lastBlockInPage.type === 'h3' || lastBlockInPage.type === 'h4'); if (lastBlockIsHeading) { currentPageBlocks.pop(); currentHeight -= lastBlockInPage.estimatedHeight; } // 先把当前页已有的内容推出去 if (currentPageBlocks.length > 0) { contentPages.push(generateContentPage( currentPageBlocks, pageNumber, sizeConfig, false, currentPageOverflowOffset )); currentPageBlocks = []; currentHeight = 0; currentPageOverflowOffset = 0; pageNumber++; } // 超大块拆分后,若有回退的标题,加到新页开头 if (lastBlockIsHeading) { currentPageBlocks = [lastBlockInPage]; currentHeight = lastBlockInPage.estimatedHeight; } // 尝试拆分超大块 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, currentPageOverflowOffset )); currentPageBlocks = []; currentHeight = 0; currentPageOverflowOffset = 0; pageNumber++; } currentPageBlocks.push(sb); currentHeight += sb.estimatedHeight; } continue; } // 正常块:判断是否需要换页 var isHeading = block.type === 'h2' || block.type === 'h3' || block.type === 'h4'; var nextBlock = (i + 1 < blocks.length) ? blocks[i + 1] : null; if (currentHeight + block.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) { var remainingSpace = contentAreaHeight - currentHeight; if (remainingSpace > 40) { // 放入当前页,CSS overflow:hidden 截断溢出部分 currentPageBlocks.push(block); // 推出当前页(含溢出块,视觉上被 .page-content { overflow:hidden } 裁切) contentPages.push(generateContentPage( currentPageBlocks, pageNumber, sizeConfig, false, currentPageOverflowOffset )); pageNumber++; // 续接:下一页从截断处继续显示同一个块 var overflowOffset = remainingSpace; var remainingHeight = block.estimatedHeight - overflowOffset; // 处理跨多页溢出(一个块可能跨越3+页) while (remainingHeight > contentAreaHeight) { contentPages.push(generateContentPage( [block], pageNumber, sizeConfig, false, overflowOffset )); pageNumber++; overflowOffset += contentAreaHeight; remainingHeight -= contentAreaHeight; } // 最后一部分续接,后续块可以继续添加到这一页 currentPageBlocks = [block]; currentHeight = remainingHeight; currentPageOverflowOffset = overflowOffset; // 块已在上方处理完毕,跳过底部的默认 push continue; } // 剩余空间太小(<40px),不值得截断,直接换页 contentPages.push(generateContentPage( currentPageBlocks, pageNumber, sizeConfig, false, currentPageOverflowOffset )); currentPageBlocks = []; currentHeight = 0; currentPageOverflowOffset = 0; pageNumber++; } currentPageBlocks.push(block); currentHeight += block.estimatedHeight; // Orphan control: 标题后至少跟一个内容块,若下一个块放不下则标题移到下一页 if (isHeading && nextBlock) { if (currentHeight + nextBlock.estimatedHeight > contentAreaHeight) { currentPageBlocks.pop(); currentHeight -= block.estimatedHeight; if (currentPageBlocks.length > 0) { contentPages.push(generateContentPage( currentPageBlocks, pageNumber, sizeConfig, false, currentPageOverflowOffset )); pageNumber++; } currentPageBlocks = [block]; currentHeight = block.estimatedHeight; currentPageOverflowOffset = 0; } } } // 最后一页 if (currentPageBlocks.length > 0) { contentPages.push(generateContentPage( currentPageBlocks, pageNumber, sizeConfig, true, currentPageOverflowOffset )); } return contentPages; } /** * 拆分超大块(超长段落文本拆成多段) * @param {Object} block - 块级元素 * @param {number} pageHeight - 可用内容高度 * @returns {Array} 拆分后的块数组 */ /** * 将超大表格按行拆分为多个表格块 * 保留表头(thead或第一行tr)在每个拆分后的表格中 * @param {Object} block - 表格块 { type:'table', html, estimatedHeight } * @param {number} pageHeight - 可用内容高度 * @returns {Array} 拆分后的块数组 */ function splitTableByRows(block, pageHeight) { var $table = $(block.html); var $allRows = $table.find('tr'); if ($allRows.length <= 1) return [block]; // 提取表头(thead 或第一行) var $thead = $table.find('thead'); var headerHtml = ''; var dataStartIdx = 0; if ($thead.length > 0) { headerHtml = '' + $thead.html() + ''; dataStartIdx = 0; // 数据行从 thead 之后开始 // 检查 tbody var $tbody = $table.find('tbody'); if ($tbody.length > 0) { $allRows = $tbody.find('tr'); } } else { // 第一行作为表头 headerHtml = '' + $allRows.eq(0).html() + ''; dataStartIdx = 1; } // 估算每行高度 var rowCount = $allRows.length - dataStartIdx; if (rowCount <= 0) return [block]; var perRowHeight = block.estimatedHeight / ($allRows.length); // 表头高度 var headerHeight = perRowHeight; // 每页能放的数据行数 var rowsPerPage = Math.floor((pageHeight - headerHeight) / perRowHeight); if (rowsPerPage < 1) rowsPerPage = 1; // 保留原始表格属性(class, style 等) var tableAttrs = ''; var attrs = $table[0].attributes; for (var a = 0; a < attrs.length; a++) { if (attrs[a].name !== 'class' || attrs[a].value.indexOf('w-e') === -1) { tableAttrs += ' ' + attrs[a].name + '="' + attrs[a].value + '"'; } } var result = []; for (var r = dataStartIdx; r < $allRows.length; r += rowsPerPage) { var chunkHtml = ''; chunkHtml += headerHtml; chunkHtml += ''; var end = Math.min(r + rowsPerPage, $allRows.length); for (var ri = r; ri < end; ri++) { chunkHtml += '' + $allRows.eq(ri).html() + ''; } chunkHtml += ''; var chunkRows = end - r + (headerHtml ? 1 : 0); result.push({ type: 'table', html: chunkHtml, estimatedHeight: Math.round(chunkRows * perRowHeight) }); } return result.length > 0 ? result : [block]; } function splitOversizedBlock(block, pageHeight) { // 图片块不拆分,保留原样(会被截断但保持完整) if (block.type === 'img') { return [block]; } // 表格块:按行拆分 if (block.type === 'table') { return splitTableByRows(block, pageHeight); } // 代码块不拆分,保留原样 if (block.type === 'pre') { 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]; } // 按句子拆分:句号、问号、叹号、换行(兼容不支持 lookbehind 的浏览器) var effectiveFontSize = 14 * (config.fontScale || 1); var parts = text.split(/([。!?\n])/); var sentences = []; var current = ''; for (var si = 0; si < parts.length; si++) { current += parts[si]; if (/[。!?\n]/.test(parts[si]) && current.length > 0) { sentences.push(current); current = ''; } } if (current) sentences.push(current); if (sentences.length <= 1) { // 无法按句子拆分,按固定字符数拆分 sentences = []; var chunkSize = Math.floor(500 / effectiveFontSize) * Math.floor(pageHeight / (effectiveFontSize * 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 lineChars = Math.floor(500 / effectiveFontSize); if (lineChars < 1) lineChars = 1; var lines = Math.ceil(s.length / lineChars); var h = lines * effectiveFontSize * 1.8 + 10; 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; } // ===== 品牌头部 ===== var BRAND_HEADER_HEIGHT = 36; var brandLogoBase64 = null; function generateBrandHeader() { if (!postData.site_name && !postData.site_logo) return ''; var html = '
'; if (postData.site_logo) { var logoSrc = brandLogoBase64 || postData.site_logo; html += ''; } if (postData.site_name) { html += '' + escapeHtml(postData.site_name) + ''; } html += '
'; return html; } function preloadBrandLogo() { if (!postData.site_logo) return; if (postData.site_logo.indexOf('avatar.png') !== -1) { postData.site_logo = ''; return; } try { var img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function() { var canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext('2d').drawImage(img, 0, 0); brandLogoBase64 = canvas.toDataURL('image/png'); }; img.src = postData.site_logo; } catch(e) { // base64 conversion failed, use original URL } } /** * 生成封面页HTML */ function generateCoverPage(sizeConfig) { var hasCover = hasValidPoster(); var html = '
'; html += generateBrandHeader(); 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 += '
'; } // 水印 if (config.watermark) { html += '
' + escapeHtml(config.watermark) + '
'; } html += '
'; return { type: 'cover', html: html }; } /** * 生成内容页HTML(含逐页对齐支持) */ function generateContentPage(blocks, pageNum, sizeConfig, isLast, overflowOffset) { // 读取逐页对齐配置 var alignment = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top'; var valignClass = ''; if (alignment === 'center') { valignClass = ' valign-center'; } else if (alignment === 'bottom') { valignClass = ' valign-bottom'; } var html = '
'; html += generateBrandHeader(); // 页头(仅第一页内容页显示标题) if (pageNum === 1) { html += ''; } // 作者声明(所有内容页) if (postData.author_name) { html += '
文/' + escapeHtml(postData.author_name) + '
'; } // 正文内容区 html += '
'; // 续接页:用内层div+负margin-top偏移,使截断处之后的内容可见 if (overflowOffset && overflowOffset > 0) { html += '
'; } for (var i = 0; i < blocks.length; i++) { html += blocks[i].html; } if (overflowOffset && overflowOffset > 0) { html += '
'; } html += '
'; // 页脚 - 占位,render() 中会替换页码 html += ''; // 水印 if (config.watermark) { html += '
' + escapeHtml(config.watermark) + '
'; } html += '
'; return { type: 'content', html: html, pageNum: pageNum }; } /** * 生成尾页/总结页HTML */ function generateSummaryPage(sizeConfig, totalPages) { var html = '
'; html += generateBrandHeader(); html += '
感谢阅读
'; html += '
' + escapeHtml(postData.title) + '
'; if (postData.desc) { html += '
' + escapeHtml(postData.desc) + '
'; } html += ''; // 水印 if (config.watermark) { html += '
' + escapeHtml(config.watermark) + '
'; } html += '
'; return { type: 'summary', html: html }; } // ===== 纯图片页优化 ===== /** * @deprecated 纯图片页已统一走SnapDOM截图流程,此函数不再被调用 * 检测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; // 跳过由表格/代码块转换而来的图片(不应被当作纯图片页处理) if ($imgs.first().attr('data-converted') === 'true') return false; // img必须有有效src var src = $imgs.first().attr('src'); return src && src.length > 0; } /** * @deprecated 纯图片页已统一走SnapDOM截图流程,此函数不再被调用 * 从纯图片页元素中获取img的src * @param {jQuery} $pageElem * @returns {string} */ function getPureImageSrc($pageElem) { var $img = $pageElem.find('.page-content img').first(); return $img.attr('src') || ''; } /** * @deprecated DOM预览模式下不再使用Canvas绘制纯图片页。 * 纯图片页已统一使用HTML结构(品牌header+居中图片+页脚)渲染。 * 保留函数供JPEG截图备用路径使用。 * 将原始图片绘制到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 = img.naturalWidth; canvas.height = img.naturalHeight; var ctx = canvas.getContext('2d'); // 白色背景 ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 按原图尺寸绘制,不缩放 ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight); deferred.resolve(canvas); }; img.onerror = function () { deferred.reject('Image load failed'); }; img.src = src; return deferred.promise(); } /** * 共享截图核心逻辑 - 将pages渲染到staging并逐页截图 * @param {Object} opts - { scale, outputCanvas, quality, sizeConfig } * scale: SnapDOM缩放 (1=缩略图, 2=保存) * outputCanvas: true=输出canvas对象, false=输出dataURL字符串 * quality: JPEG质量 (0-1) * sizeConfig: 尺寸配置(用于纯图片页canvas渲染) * @returns {jQuery Deferred} resolves with array of dataURL strings or canvas objects */ function doCapturePages(opts, pageProgressCallback) { var deferred = $.Deferred(); ensureStaging(); applyFontScale(); var $staging = $('#render-staging'); $staging.empty(); // 渲染所有页面到隐藏区域 var sizeClass = 'size-' + config.size; var $wrapper = $('
'); for (var i = 0; i < pages.length; i++) { $wrapper.append(pages[i].html); } $staging.append($wrapper); // 等待字体和DOM就绪 var fontsReady = (typeof document.fonts !== 'undefined' && document.fonts.ready) ? document.fonts.ready : $.Deferred().resolve().promise(); fontsReady.catch(function () { // 字体加载失败不影响截图,继续执行 }).then(function () { requestAnimationFrame(function () { // SnapDOM会跳过visibility:hidden的元素,临时切换为可见 $staging.css({ visibility: 'visible' }); // 表格和代码块已在中间栏中转为图片,无需再次转换 runCaptureLoop($staging, opts, deferred, pageProgressCallback); }); }); return deferred.promise(); } /** * 执行串行截图循环 * @param {jQuery} $staging - staging元素 * @param {Object} opts - 截图选项 * @param {jQuery Deferred} deferred - 外部deferred */ function runCaptureLoop($staging, opts, deferred, pageProgressCallback) { var $pageElems = $staging.find('.phone-image-page'); if ($pageElems.length === 0) { $staging.css({ visibility: 'hidden' }); if (opts.outputCanvas) { deferred.reject('没有可渲染的页面'); } else { deferred.resolve([]); } return; } var results = []; var idx = 0; var total = $pageElems.length; function captureNext() { if (idx >= total) { $staging.empty(); $staging.css({ visibility: 'hidden' }); deferred.resolve(results); return; } var $elem = $pageElems.eq(idx); // 纯图片页统一走SnapDOM截图,不再跳过截图流程 // 这样纯图片页也会包含品牌header和页码 // 统一截图流程 capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, function () { if (opts.streaming && opts.onPageReady) opts.onPageReady(results[results.length - 1], idx); if (pageProgressCallback) pageProgressCallback(idx + 1, total); idx++; captureNext(); }); } captureNext(); } /** * 用SnapDOM截图单页 */ function capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, onDone) { snapdom.toCanvas($elem[0], { scale: opts.scale, backgroundColor: '#ffffff' }).then(function (canvas) { if (opts.outputCanvas) { results.push(canvas); } else { results.push(canvas.toDataURL('image/jpeg', opts.quality)); } onDone(); }).catch(function (err) { $staging.empty(); $staging.css({ visibility: 'hidden' }); deferred.reject('截图失败: ' + err); }); } /** * 渲染到隐藏区域 -> 截图 -> 显示缩略图 * @param {Object} sizeConfig * @returns {jQuery Deferred} resolves with pages array */ function renderThumbnails(sizeConfig) { var deferred = $.Deferred(); // 流式渲染:先清空容器,再逐页追加缩略图 initThumbnails(sizeConfig); doCapturePages({ scale: 1, outputCanvas: false, quality: 0.85, sizeConfig: sizeConfig, streaming: true, onPageReady: function (dataUrl, pageIndex) { appendThumbnail(dataUrl, pageIndex, sizeConfig, pages[pageIndex]); } }, function (current, total) { PhoneImageLogPanel.log('截图: 第 ' + current + '/' + total + ' 页', 'info'); }).then(function (dataUrls) { updateThumbnailPageNumbers(dataUrls.length); deferred.resolve(pages); }).catch(function (err) { deferred.reject(err); }); return deferred.promise(); } /** * 代码块预渲染:将超长代码块(>contentAreaHeight)用SnapDOM截图替换为img * 短代码块保持HTML渲染 */ async function preRenderCodeBlocks(contentAreaHeight) { var previewEl = document.getElementById('render-preview'); if (!previewEl) return; var preElements = previewEl.querySelectorAll('pre'); for (var i = 0; i < preElements.length; i++) { var preEl = preElements[i]; var rect = preEl.getBoundingClientRect(); // 只对超出一页高度的代码块预渲染 if (rect.height > contentAreaHeight) { try { var canvas = await snapdom.toCanvas(preEl, { scale: 2, backgroundColor: '#f6f8fa' }); var dataUrl = canvas.toDataURL('image/png'); // 创建替换img元素 var imgEl = document.createElement('img'); imgEl.setAttribute('data-converted', 'true'); imgEl.setAttribute('data-original-width', Math.round(rect.width)); imgEl.setAttribute('data-original-height', Math.round(rect.height)); imgEl.setAttribute('src', dataUrl); imgEl.style.maxWidth = '100%'; imgEl.style.display = 'block'; imgEl.style.margin = '10px 0'; // 替换原pre元素 preEl.parentNode.replaceChild(imgEl, preEl); } catch(e) { // SnapDOM截图失败时保留原pre console.warn('Code block pre-render failed:', e); } } } } /** * DOM分页预览 - 将pages[]转换为domPages格式 * 替代canvas截图方案,直接操作DOM渲染 */ function renderDomPages() { var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; // 清空domPages domPages = []; // 如果没有pages数据,返回 if (!pages || pages.length === 0) { return; } // 遍历pages,创建domPage对象 for (var i = 0; i < pages.length; i++) { var page = pages[i]; var pageHtml = page.html || ''; var pageType = page.type || 'content'; // 检测纯图片页:内容页中 .page-content 内仅有1个img且无文字 var isPureImage = false; if (pageType === 'content') { var $tempDiv = $('
').html(pageHtml); var $pageContent = $tempDiv.find('.page-content'); if ($pageContent.length > 0) { var contentText = $pageContent.text().replace(/\s/g, ''); if (contentText.length === 0) { var $imgs = $pageContent.find('img'); if ($imgs.length === 1) { var imgSrc = $imgs.first().attr('src'); if (imgSrc && imgSrc.length > 0 && $imgs.first().attr('data-converted') !== 'true') { isPureImage = true; // 为纯图片页调整 .page-content 样式:居中显示图片,保持原始宽高比 $pageContent.css({ 'display': 'flex', 'align-items': 'center', 'justify-content': 'center' }); $imgs.first().css({ 'max-width': '100%', 'max-height': '100%', 'object-fit': 'contain' }); pageHtml = $tempDiv.html(); } } } } } var domPage = { html: pageHtml, type: pageType, pageNum: page.pageNum || (i + 1), valign: (config.pageAlignments && config.pageAlignments[i]) || 'top', isPureImage: isPureImage }; domPages.push(domPage); } totalDomPages = domPages.length; currentDomPageIndex = 0; // 调用渲染 renderDomPageThumbnails(sizeConfig); } /** * DOM分页预览 - 渲染所有页面的缩略图 * 将domPages[]逐页创建为DOM元素,替代JPEG截图方案 * 每页使用transform:scale()缩放显示,保证与最终输出一致 * @param {Object} sizeConfig - {width, height} */ function renderDomPageThumbnails(sizeConfig) { var $preview = $('#paginated-preview'); $preview.empty(); if (!domPages || domPages.length === 0) { return; } // 确保size class在父容器上(CSS尺寸约束依赖此class) var $parent = $preview.closest('.paginated-preview-area'); $parent.removeClass('size-xiaohongshu size-douyin').addClass('size-' + config.size); // 计算缩略图缩放比例:容器高度 - 80(页码标签+上下padding) var containerHeight = $preview.parent().height() || 700; var scaleRatio = (containerHeight - 80) / sizeConfig.height; var thumbWidth = Math.round(sizeConfig.width * scaleRatio); // 设置预览容器为水平滚动布局 $preview.css({ 'display': 'flex', 'flex-direction': 'row', 'gap': '20px', 'padding': '20px', 'flex-wrap': 'nowrap', 'overflow-x': 'auto' }); var totalPages = domPages.length; PhoneImageLogPanel.log('开始渲染DOM预览,共 ' + totalPages + ' 页', 'info'); // 逐页创建DOM缩略图 for (var i = 0; i < domPages.length; i++) { var domPage = domPages[i]; var pageType = domPage.type; // 创建缩略图包装器(参照appendThumbnail的交互层模式) var $thumbItem = $('
').css({ 'flex-shrink': '0', 'width': thumbWidth + 'px', 'position': 'relative' }); // 创建DOM分页容器(实际尺寸,通过transform缩放显示为缩略图) var $container = $('
').css({ 'transform': 'scale(' + scaleRatio + ')', 'transform-origin': 'top left', 'width': sizeConfig.width + 'px', 'height': sizeConfig.height + 'px' }); // 纯图片页特殊处理 if (domPage.isPureImage) { $container.addClass('dom-page-pure-image'); } // 将完整页面HTML放入容器 // pages[].html 是完整的 .phone-image-page 结构(含品牌header、内容、页码、水印) $container.html(domPage.html); $thumbItem.append($container); // 页码标签(N/M格式) var $pageNum = $('').text((i + 1) + '/' + totalPages); $thumbItem.append($pageNum); // 对齐select(仅内容页显示) if (pageType === 'content') { var pageNum = domPage.pageNum || (i + 1); var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top'; var $select = $(''); $thumbItem.append($select); } $preview.append($thumbItem); // 渲染进度日志(每5页或最后一页输出) if ((i + 1) % 5 === 0 || i === domPages.length - 1) { PhoneImageLogPanel.log('渲染DOM页面 ' + (i + 1) + '/' + totalPages, 'info'); } } } /** * DOM分页预览 - 刷新指定页面的对齐方式 * 更新 .phone-image-page 上的 valign class * @param {number} pageIndex - 页面索引(0-based) */ function refreshDomPage(pageIndex) { var $containers = $('#paginated-preview .dom-page-container'); if (pageIndex >= 0 && pageIndex < $containers.length) { var $phonePage = $containers.eq(pageIndex).find('.phone-image-page'); if ($phonePage.length) { $phonePage.removeClass('valign-top valign-center valign-bottom'); var alignment = (config.pageAlignments && config.pageAlignments[pageIndex + 1]) || 'top'; $phonePage.addClass('valign-' + alignment); } } } /** * 初始化缩略图容器(流式渲染第一步:清空容器) * @param {Object} sizeConfig */ function initThumbnails(sizeConfig) { var $preview = $('#paginated-preview'); if (!$preview.length) return; $preview.empty(); } /** * 追加单个缩略图到预览区(流式渲染:每页截图完成后立即追加) * @param {string} dataUrl - base64 图片数据或图片src * @param {number} pageIndex - 页面索引(0-based) * @param {Object} sizeConfig * @param {Object} pageData - 页面对象 { type, pageNum, ... } */ function appendThumbnail(dataUrl, pageIndex, sizeConfig, pageData) { var $preview = $('#paginated-preview'); if (!$preview.length) return; // 计算缩放比例:缩略图高度 = 容器高度 - 40(页码标签) - 40(上下padding) var containerHeight = $preview.parent().height() || 700; var scaleRatio = (containerHeight - 80) / sizeConfig.height; var thumbWidth = Math.round(sizeConfig.width * scaleRatio); var $item = $('
'); $item.css('width', thumbWidth + 'px'); var $img = $(''); $img.attr('src', dataUrl); $img.css('width', thumbWidth + 'px'); $item.append($img); // 页码(初始只显示序号,全部完成后更新为 N/M) var $pageNum = $('' + (pageIndex + 1) + ''); $item.append($pageNum); // 对齐下拉(仅内容页) if (pageData && pageData.type === 'content') { var pageNum = pageData.pageNum || (pageIndex); var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top'; var $select = $(''); $item.append($select); } $preview.append($item); } /** * 更新所有缩略图页码为 N/M 格式(流式渲染全部完成后调用) * @param {number} totalPages */ function updateThumbnailPageNumbers(totalPages) { var $items = $('#paginated-preview .preview-thumb-item'); $items.each(function (i) { $(this).find('.preview-thumb-page-num').text((i + 1) + '/' + totalPages); }); } /** * 从右侧DOM分页容器截图(DOM预览模式) * 串行截图每个 .dom-page-container,避免并行导致内存溢出 * @param {Function} [onProgress] 进度回调 function(current, total) * @returns {jQuery Deferred} resolves with canvas array */ function captureDomPages(onProgress) { var deferred = $.Deferred(); var containers = document.querySelectorAll('#paginated-preview .dom-page-container'); if (!containers || containers.length === 0) { deferred.reject('未找到DOM分页容器'); return deferred.promise(); } var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; var outputWidth = sizeConfig.width * 2; // scale=2 var outputHeight = sizeConfig.height * 2; var totalPages = containers.length; var canvases = []; PhoneImageLogPanel.log('DOM截图开始,共' + totalPages + '页', 'info'); var idx = 0; function captureNext() { if (idx >= totalPages) { PhoneImageLogPanel.log('DOM截图完成,成功' + canvases.length + '/' + totalPages + '页', 'success'); deferred.resolve(canvases); return; } // 进度显示 if (onProgress) onProgress(idx + 1, totalPages); PhoneImageLogPanel.log('截图中 ' + (idx + 1) + '/' + totalPages, 'info'); try { snapdom.toCanvas(containers[idx], { scale: 2, backgroundColor: '#ffffff' }).then(function (canvas) { // 确保尺寸正确 if (canvas.width !== outputWidth || canvas.height !== outputHeight) { var resizedCanvas = document.createElement('canvas'); resizedCanvas.width = outputWidth; resizedCanvas.height = outputHeight; var ctx = resizedCanvas.getContext('2d'); ctx.drawImage(canvas, 0, 0, outputWidth, outputHeight); canvas = resizedCanvas; } canvases.push(canvas); idx++; captureNext(); }).catch(function (err) { PhoneImageLogPanel.log('第' + (idx + 1) + '页截图失败: ' + err, 'error'); // 继续下一页 idx++; captureNext(); }); } catch (e) { PhoneImageLogPanel.log('第' + (idx + 1) + '页截图异常: ' + e, 'error'); idx++; captureNext(); } } captureNext(); return deferred.promise(); } /** * 将canvas数组拼接为长图base64 * @param {Array} canvases - canvas对象数组 * @returns {jQuery Deferred} resolves with base64 string or null */ function generateLongImageFromCanvases(canvases) { var deferred = $.Deferred(); if (!canvases || canvases.length === 0) { deferred.resolve(null); return deferred.promise(); } var maxWidth = 0; var totalHeight = 0; for (var i = 0; i < canvases.length; i++) { totalHeight += canvases[i].height; if (canvases[i].width > maxWidth) { maxWidth = canvases[i].width; } } var longCanvas = document.createElement('canvas'); longCanvas.width = maxWidth; longCanvas.height = totalHeight; var ctx = longCanvas.getContext('2d'); var y = 0; for (var j = 0; j < canvases.length; j++) { ctx.drawImage(canvases[j], 0, y); y += canvases[j].height; } deferred.resolve(longCanvas.toDataURL('image/jpeg', 0.85)); return deferred.promise(); } /** * 将页面渲染到隐藏区域并高质量截图(用于保存,旧流程备用) * @returns {jQuery Deferred} resolves with canvas array */ function capturePagesFromStaging() { var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; return doCapturePages({ scale: 2, outputCanvas: true, quality: 0.92, sizeConfig: sizeConfig }); } // ===== 图片生成 ===== /** * 逐页截图生成图片(使用隐藏渲染区域高质量截图) * @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(); // 第一步:生成分页图片和长图 // DOM预览模式:直接从右侧DOM容器截图;旧模式:从隐藏渲染区截图 var capturePromise; if (config.useDomPreview) { capturePromise = captureDomPages(function (current, total) { if (onProgress) onProgress(current, total, null); }); } else { capturePromise = capturePagesFromStaging(); } capturePromise.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)); } // 第二步:生成长图(DOM模式用canvas拼接,旧模式用编辑器DOM截图) var longImagePromise; if (config.useDomPreview) { longImagePromise = generateLongImageFromCanvases(canvases); } else { longImagePromise = generateLongImageBase64(); } return longImagePromise.then(function(longBase64) { return { pages: pagesData, longImage: longBase64 }; }); }).then(function(result) { var pagesData = result.pages; var longImageData = result.longImage; // 检查数据总大小,防止超大文章导致请求失败 var totalSize = 0; for (var i = 0; i < pagesData.length; i++) { if (pagesData[i]) totalSize += pagesData[i].length; } if (longImageData) totalSize += longImageData.length; if (totalSize > 16 * 1024 * 1024) { layer.msg('数据量过大(超过16MB),请减少内容或联系管理员'); deferred.reject('数据量过大(超过16MB)'); return; } // 第三步:发送到后端 // 合并保存配置,确保pageAlignments来自引擎内部config var mergedConfig = $.extend({}, saveConfig || config, { pageAlignments: config.pageAlignments || {} }); $.ajax({ url: '/index.php/admin/post/savePostOutput', type: 'POST', data: JSON.stringify({ post_id: postId, output_type: 'phone_image', config: mergedConfig, content_html: postData.content_html, pages: pagesData, long_image: longImageData }), contentType: 'application/json', success: function (result) { if (result.code === 0) { deferred.resolve(result.data); } else { deferred.reject(result.msg || '保存失败'); } }, error: function (xhr) { // 容错:后端可能返回200 JSON + 500错误页面的混合响应 // 尝试从响应文本开头提取有效JSON try { var text = xhr.responseText || ''; var jsonEnd = text.indexOf('}'); if (jsonEnd > 0) { var jsonStr = text.substring(0, jsonEnd + 1); var parsed = JSON.parse(jsonStr); if (parsed && parsed.code === 0) { deferred.resolve(parsed.data || {}); return; } } } catch (e) { /* 解析失败,走正常错误流程 */ } deferred.reject('网络错误: ' + xhr.statusText); } }); }).catch(function (err) { deferred.reject(err); }); return deferred.promise(); } /** * 保存排版配置到服务端(不生成图片) * @param {number} postId 文章ID * @param {Object} saveConfig 配置信息 {size, fontSize, watermark, content_html} * @param {string} url 保存接口URL * @returns {Promise} jQuery Deferred, resolves with {output_id} */ function saveConfig(postId, saveConfig, url) { var deferred = $.Deferred(); var payload = { post_id: postId, output_type: 'phone_image', config: { size: saveConfig.size || config.size, watermark: saveConfig.watermark || config.watermark, fontScale: config.fontScale || 1, tableFontScale: config.tableFontScale || 1, codeFontScale: config.codeFontScale || 1, pageAlignments: config.pageAlignments || {} }, content_html: saveConfig.content_html || postData.content_html }; $.ajax({ url: url || '', type: 'POST', data: JSON.stringify(payload), contentType: 'application/json', success: function (result) { if (result.code === 0) { deferred.resolve(result.data || {}); } else { deferred.reject(result.msg || '保存失败'); } }, error: function (xhr) { // 容错:后端可能返回200 JSON + 500错误页面的混合响应 try { var text = xhr.responseText || ''; var jsonEnd = text.indexOf('}'); if (jsonEnd > 0) { var jsonStr = text.substring(0, jsonEnd + 1); var parsed = JSON.parse(jsonStr); if (parsed && parsed.code === 0) { deferred.resolve(parsed.data || {}); return; } } } catch (e) { /* 解析失败,走正常错误流程 */ } deferred.reject('网络错误: ' + xhr.statusText); } }); return deferred.promise(); } // ===== 逐页对齐 ===== /** * 设置指定页的对齐方式 * @param {number} pageNum 页码(1-based) * @param {string} align 'top'|'center' */ function setPageAlignment(pageNum, align) { if (!config.pageAlignments) { config.pageAlignments = {}; } config.pageAlignments[pageNum] = align; } // ===== 对齐防抖保存 ===== var _saveConfigTimer = null; /** * 防抖保存config(对齐变更时使用) * 避免频繁触发保存请求,300ms内多次调用只执行最后一次 */ function debouncedSaveConfig() { if (_saveConfigTimer) clearTimeout(_saveConfigTimer); _saveConfigTimer = setTimeout(function () { // 触发自定义事件,由phone_image.html中的监听器处理实际保存 $(document).trigger('phone-image:save-config'); }, 300); } // ===== 长图base64生成(不触发下载) ===== /** * 生成长图base64(不触发下载) * @returns {jQuery Deferred} resolves with base64 string or null */ function generateLongImageBase64() { var deferred = $.Deferred(); var editorChildren = getEditorChildren(); if (editorChildren.length === 0) { deferred.resolve(null); return deferred.promise(); } var $container = $('
').css({ width: '540px', position: 'fixed', left: '-9999px', top: '0', visibility: 'visible', background: '#ffffff', padding: '20px', boxSizing: 'border-box', fontSize: '14px', lineHeight: '1.8' }); for (var i = 0; i < editorChildren.length; i++) { var el = editorChildren[i]; var isDivider = el.getAttribute('data-w-e-type') === 'divider' || el.tagName.toLowerCase() === 'hr'; if (!isDivider) { $container.append(el.cloneNode(true)); } } $('body').append($container); requestAnimationFrame(function () { snapdom.toCanvas($container[0], { scale: 2, backgroundColor: '#ffffff' }).then(function (canvas) { $container.remove(); // 使用JPEG压缩减少体积 deferred.resolve(canvas.toDataURL('image/jpeg', 0.85)); }).catch(function (err) { $container.remove(); // 长图生成失败不阻断主流程 console.warn('长图生成失败:', err); deferred.resolve(null); }); }); return deferred.promise(); } // ===== 导出长图 ===== /** * 导出长图:从 wangeditor DOM 截取完整长图 * @returns {jQuery Deferred} resolves with canvas */ function exportLongImage() { var deferred = $.Deferred(); if (render._locked) { deferred.reject('rendering'); return deferred.promise(); } var editorChildren = getEditorChildren(); if (editorChildren.length === 0) { deferred.reject('编辑器内容为空'); return deferred.promise(); } // 创建临时容器,宽度540px与编辑器一致 var $container = $('
').css({ width: '540px', position: 'fixed', left: '-9999px', top: '0', visibility: 'visible', background: '#ffffff', padding: '20px', boxSizing: 'border-box', fontSize: '14px', lineHeight: '1.8' }); // 克隆所有非 divider 的子元素 for (var i = 0; i < editorChildren.length; i++) { var el = editorChildren[i]; var isDivider = el.getAttribute('data-w-e-type') === 'divider' || el.tagName.toLowerCase() === 'hr'; if (!isDivider) { $container.append(el.cloneNode(true)); } } $('body').append($container); requestAnimationFrame(function () { snapdom.toCanvas($container[0], { scale: 2, backgroundColor: '#ffffff' }).then(function (canvas) { $container.remove(); 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) { $container.remove(); 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; } // ===== 对齐下拉事件委托(兼容JPEG预览和DOM预览两种模式) ===== $(document).on('change', '.thumb-alignment-select', function () { var $select = $(this); var pageIndex = parseInt($select.attr('data-page-index'), 10); var pageNum = parseInt($select.attr('data-page-num'), 10); var align = $select.val(); // 更新引擎内部config setPageAlignment(pageNum, align); var $thumbItem = $select.closest('.preview-thumb-item'); // DOM预览模式:更新 .phone-image-page 的 valign class var $phonePage = $thumbItem.find('.dom-page-container .phone-image-page'); if ($phonePage.length) { $phonePage.removeClass('valign-top valign-center valign-bottom').addClass('valign-' + align); // 额外刷新确保对齐一致 refreshDomPage(pageIndex); } else { // JPEG预览模式:更新 img 的 align-self var $thumbImg = $thumbItem.find('.preview-thumb-img'); if (align === 'center') { $thumbImg.css({ 'align-self': 'center' }); } else if (align === 'bottom') { $thumbImg.css({ 'align-self': 'flex-end' }); } else { $thumbImg.css({ 'align-self': 'flex-start' }); } } // 防抖自动保存config debouncedSaveConfig(); }); // ===== 公开API ===== return { init: init, render: render, generateImages: generateImages, saveImages: saveImages, saveConfig: saveConfig, syncPreview: syncPreview, setPageAlignment: setPageAlignment, exportLongImage: exportLongImage, renderDomPages: renderDomPages, renderDomPageThumbnails: renderDomPageThumbnails, refreshDomPage: refreshDomPage, debouncedSaveConfig: debouncedSaveConfig, logPanel: PhoneImageLogPanel, getContentHtml: function () { if (window.phoneImageEditor) { return window.phoneImageEditor.getHtml(); } return postData.content_html; }, updateConfig: function (newConfig) { if (newConfig) { $.extend(config, newConfig); } applyFontScale(); } }; })();