/** * 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 = []; // ===== 截图缓存 ===== var convertedBlockCache = {}; // key: simpleHash(block.html), value: { imgHtml: '...' } /** * 简单字符串哈希 (djb2 变体) * @param {string} str * @returns {string} 哈希字符串 */ function simpleHash(str) { var hash = 0; if (!str) return '0'; for (var i = 0; i < str.length; i++) { var char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // 转32位整数 } return '' + hash; } /** * 确保隐藏渲染区域存在 */ 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); } // 清空缓存,避免旧数据干扰 convertedBlockCache = {}; // 内容流事件委托(只绑定一次) $(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 * 管线: preprocess -> parse -> renderContentFlow -> measure -> paginate -> generate -> renderThumbnails * 带并发锁:防止多次 render 同时执行造成数据混乱 * @returns {jQuery Deferred} resolves with pages array */ function render() { var deferred = $.Deferred(); // 防止并发渲染 if (render._locked) { // 标记有待处理的渲染请求,当前渲染完成后会自动重试 render._pending = true; return deferred.reject('rendering').promise(); } render._locked = true; 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); // 动态设置字号CSS变量,让fontSize滑块真正生效 var effectiveFontSize = parseInt(config.fontSize, 10) || 14; document.documentElement.style.setProperty('--pi-font-size-base', effectiveFontSize + 'px'); // 缓存清理:如果缓存条目过多则清空,防止内存膨胀 var cacheKeys = Object.keys(convertedBlockCache); if (cacheKeys.length > blocks.length * 3) { convertedBlockCache = {}; } // 空内容检测:blocks 为空时直接提示,不进入渲染流程 if (blocks.length === 0) { $('#paginated-preview').html('
文章正文内容为空
'); render._locked = false; return deferred.resolve([]).promise(); } // 先渲染内容流(DOM) - 用于实测高度 renderContentFlow(blocks); // 等待一帧确保中间栏渲染完成 requestAnimationFrame(function () { // 在中间栏中将表格和代码块截图转为图片 convertFlowBlocksToImages(blocks).then(function () { // 再等一帧让替换后的img渲染 requestAnimationFrame(function () { measureBlockHeights(blocks); // 封面页 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 + '' ); } } // 渲染缩略图(右侧)- 异步截图 renderThumbnails(sizeConfig).then(function () { render._locked = false; // 如果渲染期间有新的渲染请求排队,自动触发 if (render._pending) { render._pending = false; render().catch(function () { // 静默处理递归渲染失败 }); } deferred.resolve(pages); }).catch(function (err) { render._locked = false; deferred.reject(err); }); }); }).catch(function (err) { 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, ''); // 处理图片: 保留原始尺寸到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解析为块级元素数组 * 每个块: { 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 = 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 = getContentWidth() / imgW; block.estimatedHeight = Math.round(imgH * imgRatio) + 20; } else { block.estimatedHeight = 0; block.needsMeasurement = true; } 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上下间距 } /** * 估算列表高度 */ 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]; } // 表格块不拆分,保留原样 if (block.type === 'table') { return [block]; } // 代码块不拆分,保留原样 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 = parseInt(config.fontSize, 10) || 14; 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(getContentWidth() / 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 h = estimateTextHeight(s, effectiveFontSize); 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 += '
'; } // 水印 if (config.watermark) { html += '
' + escapeHtml(config.watermark) + '
'; } 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 += ''; // 水印 if (config.watermark) { html += '
' + escapeHtml(config.watermark) + '
'; } 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 += ''; // 水印 if (config.watermark) { html += '
' + escapeHtml(config.watermark) + '
'; } html += '
'; return { type: 'summary', html: html }; } // ===== 纯图片页优化 ===== /** * 检测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; } /** * 从纯图片页元素中获取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(); } /** * 共享截图核心逻辑 - 将pages渲染到staging并逐页截图 * @param {Object} opts - { scale, outputCanvas, quality, sizeConfig } * scale: html2canvas缩放 (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) { var deferred = $.Deferred(); ensureStaging(); 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 () { // html2canvas会跳过visibility:hidden的元素,临时切换为可见 $staging.css({ visibility: 'visible' }); // 表格和代码块已在中间栏中转为图片,无需再次转换 runCaptureLoop($staging, opts, deferred); }); }); return deferred.promise(); } /** * 执行串行截图循环 * @param {jQuery} $staging - staging元素 * @param {Object} opts - 截图选项 * @param {jQuery Deferred} deferred - 外部deferred */ function runCaptureLoop($staging, opts, deferred) { 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); // 纯图片页优化 if (isPureImagePage($elem)) { var pureSrc = getPureImageSrc($elem); if (pureSrc) { if (opts.outputCanvas) { // 保存模式: 高质量绘制原图到canvas renderPureImageToCanvas(pureSrc, opts.sizeConfig.width, opts.sizeConfig.height).then(function (canvas) { results.push(canvas); idx++; captureNext(); }).catch(function () { // 加载失败,回退到html2canvas capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, function () { idx++; captureNext(); }); }); return; } else { // 缩略图模式: 直接用src results.push(pureSrc); idx++; captureNext(); return; } } } capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, function () { idx++; captureNext(); }); } captureNext(); } /** * 用html2canvas截图单页 */ function capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, onDone) { html2canvas($elem[0], { scale: opts.scale, useCORS: true, backgroundColor: '#ffffff', width: $elem.outerWidth(), height: $elem.outerHeight(), logging: false }).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(); doCapturePages({ scale: 1, outputCanvas: false, quality: 0.85, sizeConfig: sizeConfig }).then(function (dataUrls) { displayThumbnails(dataUrls, sizeConfig); deferred.resolve(pages); }).catch(function (err) { deferred.reject(err); }); 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 sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; return doCapturePages({ scale: 2, outputCanvas: true, quality: 0.92, sizeConfig: sizeConfig }); } /** * 在中间栏 #content-flow 中将表格和代码块截图转为图片 * 在 renderContentFlow(blocks) 之后、measureBlockHeights(blocks) 之前调用 * 使用 convertedBlockCache 缓存已转换的图片,仅对新增/变更的 block 执行截图 * @param {Array} blocks - parseHtmlToBlocks 返回的块数组 * @returns {jQuery Deferred} resolves with blocks array */ function convertFlowBlocksToImages(blocks) { var deferred = $.Deferred(); var $flow = $('#content-flow'); var blocksToConvert = []; // 需要重新截图的 block 索引 // 第一遍:检查缓存,命中则直接复用 for (var i = 0; i < blocks.length; i++) { if (blocks[i].type !== 'table' && blocks[i].type !== 'pre') continue; var cacheKey = simpleHash(blocks[i].html); if (convertedBlockCache[cacheKey]) { // 命中缓存 blocks[i].html = convertedBlockCache[cacheKey].imgHtml; blocks[i].type = 'img'; // 更新中间栏显示 var $cachedEl = $flow.find('.content-flow-block[data-index="' + i + '"]'); if ($cachedEl.length) $cachedEl.html(blocks[i].html); } else { blocksToConvert.push(i); } } if (blocksToConvert.length === 0) { deferred.resolve(blocks); return deferred.promise(); } // 第二遍:串行截图未缓存的 block var convertIdx = 0; function convertNext() { if (convertIdx >= blocksToConvert.length) { deferred.resolve(blocks); return; } var blockIdx = blocksToConvert[convertIdx]; var block = blocks[blockIdx]; var $blockEl = $flow.find('.content-flow-block[data-index="' + blockIdx + '"]'); if (!$blockEl.length) { convertIdx++; convertNext(); return; } // 对代码块:先应用 Prism 高亮 if (block.type === 'pre' && typeof Prism !== 'undefined') { $blockEl.find('pre > code:not([class*="language-"])').addClass('language-plaintext'); $blockEl.find('pre code').each(function () { Prism.highlightElement(this); }); } html2canvas($blockEl[0], { scale: 2, useCORS: true, backgroundColor: block.type === 'table' ? '#ffffff' : '#f5f5f5', logging: false }).then(function (canvas) { var imgData = canvas.toDataURL('image/jpeg', 0.92); var imgHtml = ''; // 存入缓存(用截图前的原始HTML做key) var originalHtml = block.html; var cacheKey = simpleHash(originalHtml); convertedBlockCache[cacheKey] = { imgHtml: imgHtml }; block.html = imgHtml; block.type = 'img'; $blockEl.html(imgHtml); convertIdx++; convertNext(); }).catch(function () { // 截图失败,保留原样 convertIdx++; convertNext(); }); } convertNext(); return deferred.promise(); } /** * DOM实测高度 - 从#content-flow中读取每个块的实际渲染高度 * @param {Array} blocks - parseHtmlToBlocks 返回的块数组 */ function measureBlockHeights(blocks) { var $flow = $('#content-flow'); if (!$flow.length) return; for (var i = 0; i < blocks.length; i++) { var block = blocks[i]; if (block.type === 'page-break') continue; var $blockEl = $flow.find('.content-flow-block[data-index="' + i + '"]'); if ($blockEl.length) { var actualHeight = Math.round($blockEl[0].getBoundingClientRect().height); if (actualHeight > 0) { block.estimatedHeight = actualHeight; } } } } /** * 渲染内容流到 #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) { // 如果正在渲染,延迟重试 if (render._locked) { insertPageBreak._retryCount = (insertPageBreak._retryCount || 0) + 1; if (insertPageBreak._retryCount >= 15) { insertPageBreak._retryCount = 0; layer.msg('操作超时,请重试'); return; } setTimeout(function () { insertPageBreak(blockIndex); }, 200); return; } insertPageBreak._retryCount = 0; 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) { // 如果正在渲染,延迟重试 if (render._locked) { removePageBreak._retryCount = (removePageBreak._retryCount || 0) + 1; if (removePageBreak._retryCount >= 15) { removePageBreak._retryCount = 0; layer.msg('操作超时,请重试'); return; } setTimeout(function () { removePageBreak(blockIndex); }, 200); return; } removePageBreak._retryCount = 0; // 统计这是第几个 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)); } // 检查数据总大小,防止超大文章导致请求失败 var totalSize = 0; for (var i = 0; i < pagesData.length; i++) { if (pagesData[i]) { totalSize += pagesData[i].length; } } if (totalSize > 16 * 1024 * 1024) { layer.msg('数据量过大(超过16MB),请减少内容或联系管理员'); deferred.reject('数据量过大(超过16MB)'); return; } $.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(); } /** * 保存排版配置到服务端(不生成图片) * @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, fontSize: saveConfig.fontSize || config.fontSize, watermark: saveConfig.watermark || config.watermark, 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) { 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; } /** * 导出内容流为长图 */ function exportLongImage() { var deferred = $.Deferred(); if (render._locked) { layer.msg('请等待渲染完成'); deferred.reject('rendering'); return deferred.promise(); } 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; } // ===== 公开API ===== return { init: init, render: render, generateImages: generateImages, saveImages: saveImages, saveConfig: saveConfig, setPageAlignment: setPageAlignment, insertPageBreak: insertPageBreak, removePageBreak: removePageBreak, exportLongImage: exportLongImage }; })();