fix(phone-image): replace estimate-based pagination with DOM-measured heights

This commit is contained in:
augushong
2026-05-03 21:04:23 +08:00
parent 2bbde158b9
commit f08957ad7e

View File

@@ -100,7 +100,7 @@ var PhoneImageEngine = (function () {
/** /**
* 渲染排版预览 - 生成分页后的HTML * 渲染排版预览 - 生成分页后的HTML
* 两遍遍历: 先算总页数, 再带页码生成 * 管线: preprocess -> parse -> renderContentFlow -> measure -> paginate -> generate -> renderThumbnails
* @returns {jQuery Deferred} resolves with pages array * @returns {jQuery Deferred} resolves with pages array
*/ */
function render() { function render() {
@@ -113,10 +113,17 @@ var PhoneImageEngine = (function () {
var cleanHtml = preprocessContent(postData.content_html); var cleanHtml = preprocessContent(postData.content_html);
var blocks = parseHtmlToBlocks(cleanHtml); var blocks = parseHtmlToBlocks(cleanHtml);
// 先渲染内容流(DOM) - 用于实测高度
renderContentFlow(blocks);
// DOM实测高度 - 等待一帧确保渲染完成
requestAnimationFrame(function () {
measureBlockHeights(blocks);
// 封面页 // 封面页
pages.push(generateCoverPage(sizeConfig)); pages.push(generateCoverPage(sizeConfig));
// 内容分页 // 内容分页(使用实测高度)
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig); var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
pages = pages.concat(contentPages); pages = pages.concat(contentPages);
@@ -135,15 +142,13 @@ var PhoneImageEngine = (function () {
} }
} }
// 渲染内容流(中间栏)- 同步
renderContentFlow(blocks);
// 渲染缩略图(右侧)- 异步截图 // 渲染缩略图(右侧)- 异步截图
renderThumbnails(sizeConfig).then(function () { renderThumbnails(sizeConfig).then(function () {
deferred.resolve(pages); deferred.resolve(pages);
}).catch(function (err) { }).catch(function (err) {
deferred.reject(err); deferred.reject(err);
}); });
});
return deferred.promise(); return deferred.promise();
} }
@@ -158,15 +163,28 @@ var PhoneImageEngine = (function () {
// 清除空段落 // 清除空段落
html = html.replace(/<p[^>]*>\s*<\/p>/gi, ''); html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
// 处理图片: 清除内联样式, 强制 max-width:100% // 处理图片: 保留原始尺寸到data属性, 强制 max-width:100%
html = html.replace(/<img([^>]*?)>/gi, function (match, attrs) { html = html.replace(/<img([^>]*?)>/gi, function (match, attrs) {
// 移除 style, width, height 内联属性 // 移除内联style
attrs = attrs.replace(/\s*style\s*=\s*"[^"]*"/gi, ''); attrs = attrs.replace(/\s*style\s*=\s*"[^"]*"/gi, '');
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*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, '');
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 '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:10px 0;">'; return '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:10px 0;">';
}); });
@@ -252,10 +270,20 @@ var PhoneImageEngine = (function () {
case 'table': case 'table':
block.type = 'table'; block.type = 'table';
block.html = $el[0].outerHTML; block.html = $el[0].outerHTML;
block.estimatedHeight = estimateTableHeight($el); block.estimatedHeight = 0;
block.needsMeasurement = true;
break; break;
case 'img': 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; block.$img = $el;
break; break;
case 'ul': case 'ul':
@@ -347,11 +375,12 @@ var PhoneImageEngine = (function () {
} }
/** /**
* 估算表格高度 * 估算表格高度 (已废弃 - DOM实测替代)
* @deprecated 使用 measureBlockHeights() 替代
*/ */
function estimateTableHeight($table) { function estimateTableHeight($table) {
var rows = $table.find('tr').length; // DOM实测将覆盖此值返回0作为占位
return rows * 36 + 20; // 36px per row (35px content + 1px border) + 20px margin return 0;
} }
// ===== 分页核心算法 ===== // ===== 分页核心算法 =====
@@ -833,18 +862,22 @@ var PhoneImageEngine = (function () {
} }
/** /**
* 渲染到隐藏区域 → 截图 → 显示缩略 * 共享截图核心逻辑 - 将pages渲染到staging并逐页截
* @param {Object} sizeConfig * @param {Object} opts - { scale, outputCanvas, quality, sizeConfig }
* @returns {jQuery Deferred} resolves with pages array * 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(); var deferred = $.Deferred();
ensureStaging(); ensureStaging();
var $staging = $('#render-staging'); var $staging = $('#render-staging');
$staging.empty(); $staging.empty();
// 渲染所有页面到隐藏区域使用wrapper以便html2canvas正确渲染 // 渲染所有页面到隐藏区域
var sizeClass = 'size-' + config.size; var sizeClass = 'size-' + config.size;
var $wrapper = $('<div class="phone-image-container ' + sizeClass + '">'); var $wrapper = $('<div class="phone-image-container ' + sizeClass + '">');
for (var i = 0; i < pages.length; i++) { for (var i = 0; i < pages.length; i++) {
@@ -852,7 +885,12 @@ var PhoneImageEngine = (function () {
} }
$staging.append($wrapper); $staging.append($wrapper);
// 等待一帧确保 DOM 渲染完成 // 等待字体和DOM就绪
var fontsReady = (typeof document.fonts !== 'undefined' && document.fonts.ready)
? document.fonts.ready
: $.Deferred().resolve().promise();
fontsReady.then(function () {
requestAnimationFrame(function () { requestAnimationFrame(function () {
// html2canvas会跳过visibility:hidden的元素临时切换为可见 // html2canvas会跳过visibility:hidden的元素临时切换为可见
$staging.css({ visibility: 'visible' }); $staging.css({ visibility: 'visible' });
@@ -860,69 +898,38 @@ var PhoneImageEngine = (function () {
convertCodeBlocks().then(function () { convertCodeBlocks().then(function () {
return convertTables(); return convertTables();
}).then(function () { }).then(function () {
var $pageElems = $staging.find('.phone-image-page'); runCaptureLoop($staging, opts, deferred);
if ($pageElems.length === 0) {
$staging.css({ visibility: 'hidden' });
displayThumbnails([], sizeConfig);
deferred.resolve(pages);
return;
}
// 串行截图每一页
var thumbnailDataUrls = [];
var idx = 0;
var total = $pageElems.length;
function captureNext() {
if (idx >= total) {
$staging.empty();
$staging.css({ visibility: 'hidden' });
displayThumbnails(thumbnailDataUrls, sizeConfig);
deferred.resolve(pages);
return;
}
var $elem = $pageElems.eq(idx);
// 纯图片页优化: 跳过html2canvas直接用原图src
if (isPureImagePage($elem)) {
var pureSrc = getPureImageSrc($elem);
if (pureSrc) {
thumbnailDataUrls.push(pureSrc);
idx++;
captureNext();
return;
}
}
html2canvas($elem[0], {
scale: 1,
useCORS: true,
backgroundColor: '#ffffff',
width: $elem.outerWidth(),
height: $elem.outerHeight(),
logging: false
}).then(function (canvas) {
thumbnailDataUrls.push(canvas.toDataURL('image/jpeg', 0.85));
idx++;
captureNext();
}).catch(function (err) {
$staging.css({ visibility: 'hidden' });
deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err);
});
}
captureNext();
}).catch(function () { }).catch(function () {
// 代码块转换失败,继续转换表格并截图流程 // 代码块转换失败,继续转换表格并截图流程
convertTables().then(function () { convertTables().then(function () {
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'); var $pageElems = $staging.find('.phone-image-page');
if ($pageElems.length === 0) { if ($pageElems.length === 0) {
$staging.css({ visibility: 'hidden' }); $staging.css({ visibility: 'hidden' });
displayThumbnails([], sizeConfig); if (opts.outputCanvas) {
deferred.resolve(pages); deferred.reject('没有可渲染的页面');
} else {
deferred.resolve([]);
}
return; return;
} }
var thumbnailDataUrls = []; var results = [];
var idx = 0; var idx = 0;
var total = $pageElems.length; var total = $pageElems.length;
@@ -930,31 +937,60 @@ var PhoneImageEngine = (function () {
if (idx >= total) { if (idx >= total) {
$staging.empty(); $staging.empty();
$staging.css({ visibility: 'hidden' }); $staging.css({ visibility: 'hidden' });
displayThumbnails(thumbnailDataUrls, sizeConfig); deferred.resolve(results);
deferred.resolve(pages);
return; return;
} }
var $elem = $pageElems.eq(idx); var $elem = $pageElems.eq(idx);
// 纯图片页优化: 跳过html2canvas直接用原图src
// 纯图片页优化
if (isPureImagePage($elem)) { if (isPureImagePage($elem)) {
var pureSrc = getPureImageSrc($elem); var pureSrc = getPureImageSrc($elem);
if (pureSrc) { if (pureSrc) {
thumbnailDataUrls.push(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++; idx++;
captureNext(); captureNext();
return; 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], { html2canvas($elem[0], {
scale: 1, scale: opts.scale,
useCORS: true, useCORS: true,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
width: $elem.outerWidth(), width: $elem.outerWidth(),
height: $elem.outerHeight(), height: $elem.outerHeight(),
logging: false logging: false
}).then(function (canvas) { }).then(function (canvas) {
thumbnailDataUrls.push(canvas.toDataURL('image/jpeg', 0.85)); if (opts.outputCanvas) {
results.push(canvas);
} else {
results.push(canvas.toDataURL('image/jpeg', opts.quality));
}
idx++; idx++;
captureNext(); captureNext();
}).catch(function (err) { }).catch(function (err) {
@@ -963,9 +999,24 @@ var PhoneImageEngine = (function () {
}); });
} }
captureNext(); /**
}); * 渲染到隐藏区域 -> 截图 -> 显示缩略图
}); * @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(); return deferred.promise();
@@ -1021,171 +1072,35 @@ var PhoneImageEngine = (function () {
* @returns {jQuery Deferred} resolves with canvas array * @returns {jQuery Deferred} resolves with canvas array
*/ */
function capturePagesFromStaging() { function capturePagesFromStaging() {
var deferred = $.Deferred();
ensureStaging();
var $staging = $('#render-staging');
$staging.empty();
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu; var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
return doCapturePages({
// 添加 size class wrapper
var sizeClass = 'size-' + config.size;
var $wrapper = $('<div class="phone-image-container ' + sizeClass + '">');
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, scale: 2,
useCORS: true, outputCanvas: true,
backgroundColor: '#ffffff', quality: 0.92,
width: $elem.outerWidth(), sizeConfig: sizeConfig
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 () { * DOM实测高度 - 从#content-flow中读取每个块的实际渲染高度
// 代码块转换失败,继续转换表格并截图流程 * @param {Array} blocks - parseHtmlToBlocks 返回的块数组
convertTables().then(function () { */
var $pageElems = $staging.find('.phone-image-page'); function measureBlockHeights(blocks) {
if ($pageElems.length === 0) { var $flow = $('#content-flow');
$staging.css({ visibility: 'hidden' }); if (!$flow.length) return;
deferred.reject('没有可渲染的页面');
return;
}
var canvases = []; for (var i = 0; i < blocks.length; i++) {
var idx = 0; var block = blocks[i];
var total = $pageElems.length; if (block.type === 'page-break') continue;
function captureNext() { var $blockEl = $flow.find('.content-flow-block[data-index="' + i + '"]');
if (idx >= total) { if ($blockEl.length) {
$staging.empty(); var actualHeight = Math.round($blockEl[0].getBoundingClientRect().height);
$staging.css({ visibility: 'hidden' }); if (actualHeight > 0) {
deferred.resolve(canvases); block.estimatedHeight = actualHeight;
return;
}
var $elem = $pageElems.eq(idx);
// 纯图片页优化: 直接绘制原图到canvas
if (isPureImagePage($elem)) {
var pureSrc = getPureImageSrc($elem);
if (pureSrc) {
renderPureImageToCanvas(pureSrc, sizeConfig.width, sizeConfig.height).then(function (canvas) {
canvases.push(canvas);
idx++;
captureNext();
}).catch(function () {
// 加载失败回退到html2canvas
html2canvas($elem[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: $elem.outerWidth(),
height: $elem.outerHeight(),
logging: false
}).then(function (canvas) {
canvases.push(canvas);
idx++;
captureNext();
}).catch(function (err) {
$staging.css({ visibility: 'hidden' });
deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err);
});
});
return;
} }
} }
html2canvas($elem[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: $elem.outerWidth(),
height: $elem.outerHeight(),
logging: false
}).then(function (canvas) {
canvases.push(canvas);
idx++;
captureNext();
}).catch(function (err) {
$staging.css({ visibility: 'hidden' });
deferred.reject('截图失败(第' + (idx + 1) + '页): ' + err);
});
} }
captureNext();
});
});
});
return deferred.promise();
} }
/** /**