diff --git a/public/static/js/phone-image.js b/public/static/js/phone-image.js index f25623d..3b69e04 100644 --- a/public/static/js/phone-image.js +++ b/public/static/js/phone-image.js @@ -100,7 +100,7 @@ var PhoneImageEngine = (function () { /** * 渲染排版预览 - 生成分页后的HTML - * 两遍遍历: 先算总页数, 再带页码生成 + * 管线: preprocess -> parse -> renderContentFlow -> measure -> paginate -> generate -> renderThumbnails * @returns {jQuery Deferred} resolves with pages array */ function render() { @@ -113,36 +113,41 @@ var PhoneImageEngine = (function () { var cleanHtml = preprocessContent(postData.content_html); var blocks = parseHtmlToBlocks(cleanHtml); - // 封面页 - pages.push(generateCoverPage(sizeConfig)); - - // 内容分页 - var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig); - pages = pages.concat(contentPages); - - // 尾页 - pages.push(generateSummaryPage(sizeConfig, pages.length)); - - // 两遍遍历: 现在知道了总页数, 给每页补上 N/M 页码 - var totalPages = pages.length; - for (var i = 0; i < pages.length; i++) { - if (pages[i].type === 'content') { - // 在页脚中添加 N/M 格式页码 - pages[i].html = pages[i].html.replace( - '' + pages[i].pageNum + '', - '' + (i + 1) + '/' + totalPages + '' - ); - } - } - - // 渲染内容流(中间栏)- 同步 + // 先渲染内容流(DOM) - 用于实测高度 renderContentFlow(blocks); - // 渲染缩略图(右侧)- 异步截图 - renderThumbnails(sizeConfig).then(function () { - deferred.resolve(pages); - }).catch(function (err) { - deferred.reject(err); + // DOM实测高度 - 等待一帧确保渲染完成 + 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 () { + deferred.resolve(pages); + }).catch(function (err) { + deferred.reject(err); + }); }); return deferred.promise(); @@ -158,15 +163,28 @@ var PhoneImageEngine = (function () { // 清除空段落 html = html.replace(/]*>\s*<\/p>/gi, ''); - // 处理图片: 清除内联样式, 强制 max-width:100% + // 处理图片: 保留原始尺寸到data属性, 强制 max-width:100% html = html.replace(/]*?)>/gi, function (match, attrs) { - // 移除 style, width, height 内联属性 + // 移除内联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 ''; }); @@ -252,10 +270,20 @@ var PhoneImageEngine = (function () { case 'table': block.type = 'table'; block.html = $el[0].outerHTML; - block.estimatedHeight = estimateTableHeight($el); + block.estimatedHeight = 0; + block.needsMeasurement = true; break; case 'img': - block.estimatedHeight = estimateImageHeight($el); + // 尝试从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; + } block.$img = $el; break; case 'ul': @@ -347,11 +375,12 @@ var PhoneImageEngine = (function () { } /** - * 估算表格高度 + * 估算表格高度 (已废弃 - DOM实测替代) + * @deprecated 使用 measureBlockHeights() 替代 */ function estimateTableHeight($table) { - var rows = $table.find('tr').length; - return rows * 36 + 20; // 36px per row (35px content + 1px border) + 20px margin + // DOM实测将覆盖此值,返回0作为占位 + return 0; } // ===== 分页核心算法 ===== @@ -833,18 +862,22 @@ var PhoneImageEngine = (function () { } /** - * 渲染到隐藏区域 → 截图 → 显示缩略图 - * @param {Object} sizeConfig - * @returns {jQuery Deferred} resolves with pages array + * 共享截图核心逻辑 - 将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 renderThumbnails(sizeConfig) { + function doCapturePages(opts) { 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++) { @@ -852,118 +885,25 @@ var PhoneImageEngine = (function () { } $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; - } + // 等待字体和DOM就绪 + var fontsReady = (typeof document.fonts !== 'undefined' && document.fonts.ready) + ? document.fonts.ready + : $.Deferred().resolve().promise(); - // 串行截图每一页 - 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); + fontsReady.then(function () { + requestAnimationFrame(function () { + // html2canvas会跳过visibility:hidden的元素,临时切换为可见 + $staging.css({ visibility: 'visible' }); + // 先转换代码块为图片 + convertCodeBlocks().then(function () { + return convertTables(); + }).then(function () { + runCaptureLoop($staging, opts, deferred); + }).catch(function () { + // 代码块转换失败,继续转换表格并截图流程 + convertTables().then(function () { + runCaptureLoop($staging, opts, deferred); }); - } - - 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(); }); }); }); @@ -971,6 +911,117 @@ var PhoneImageEngine = (function () { 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, idx, total, deferred, captureNext); + }); + return; + } else { + // 缩略图模式: 直接用src + results.push(pureSrc); + idx++; + captureNext(); + return; + } + } + } + + capturePageViaHtml2Canvas($elem, $staging, opts, results, idx, total, deferred, captureNext); + } + + captureNext(); + } + + /** + * 用html2canvas截图单页 + */ + function capturePageViaHtml2Canvas($elem, $staging, opts, results, idx, total, deferred, captureNext) { + 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)); + } + idx++; + captureNext(); + }).catch(function (err) { + $staging.css({ visibility: 'hidden' }); + deferred.reject('截图失败(第' + (idx + 1) + '页): ' + 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 图片数据 @@ -1021,171 +1072,35 @@ var PhoneImageEngine = (function () { * @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 doCapturePages({ + scale: 2, + outputCanvas: true, + quality: 0.92, + sizeConfig: sizeConfig }); + } - 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; + } + } + } } /**