Files
ulthon_information/public/static/js/phone-image.js
augushong 2233b2b71a fix(phone-image): 修复代码块/表格转换DOM索引失效bug,增加重新生成按钮和中间栏表格样式
- convertCodeBlocks/convertTables: 改为每次重新find().first()替代缓存索引
- 修复replaceWith后jQuery集合中后续元素DOM引用失效的问题
- 新增'重新生成'按钮,强制重新渲染缩略图
- 新增中间栏content-flow-block表格CSS样式
2026-05-03 22:56:34 +08:00

1420 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PhoneImageEngine - 手机图片排版引擎
*
* 将文章HTML内容自动分页并渲染为手机尺寸图片
* 依赖: jQuery, html2canvas (项目已有)
*
* 渲染宽度540px, html2canvas scale=2 输出1080px
* 小红书: 540x720 (输出1080x1440)
* 抖音: 540x960 (输出1080x1920)
*/
var PhoneImageEngine = (function () {
// ===== 配置 =====
var config = {
size: 'xiaohongshu',
fontSize: 14,
watermark: '',
pageAlignments: {}, // key=页码(1-based), value='top'|'center'
sizes: {
xiaohongshu: { width: 540, height: 720 },
douyin: { width: 540, height: 960 }
},
contentPadding: 20
};
// ===== 文章数据 =====
var postData = {
id: 0,
title: '',
desc: '',
coverText: '', // 封面文案
content_html: '',
poster: '',
author_name: '',
create_time: '',
category_name: ''
};
// ===== 分页结果 =====
var pages = [];
/**
* 确保隐藏渲染区域存在
*/
function ensureStaging() {
if ($('#render-staging').length === 0) {
$('<div id="render-staging">').css({
position: 'fixed',
left: '-9999px',
top: 0,
visibility: 'hidden'
}).appendTo('body');
}
}
/**
* 初始化引擎
* @param {Object} options - {postId, title, desc, coverText, contentHtml, poster, authorName, createTime, categoryName}
* @param {Object} userConfig - {size, fontSize, watermark}
*/
function init(options, userConfig) {
postData.id = options.postId || 0;
postData.title = options.title || '';
postData.desc = options.desc || '';
postData.coverText = options.coverText || '';
postData.content_html = options.contentHtml || '';
postData.poster = options.poster || '';
postData.author_name = options.authorName || '';
postData.create_time = options.createTime || '';
postData.category_name = options.categoryName || '';
if (userConfig) {
$.extend(config, userConfig);
}
// 内容流事件委托(只绑定一次)
$(document).off('click', '.break-inserter-btn');
$(document).off('click', '.remove-break-btn');
$(document).off('click', '.thumb-alignment-toggle');
$(document).on('click', '.break-inserter-btn', function () {
var afterIndex = parseInt($(this).parent().data('after-index'), 10);
insertPageBreak(afterIndex);
});
$(document).on('click', '.remove-break-btn', function () {
var index = parseInt($(this).data('index'), 10);
removePageBreak(index);
});
$(document).on('click', '.thumb-alignment-toggle', function () {
var pageNum = parseInt($(this).data('page-num'), 10);
var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top';
var newAlign = currentAlign === 'top' ? 'center' : 'top';
setPageAlignment(pageNum, newAlign);
render();
});
}
/**
* 渲染排版预览 - 生成分页后的HTML
* 管线: preprocess -> parse -> renderContentFlow -> measure -> paginate -> generate -> renderThumbnails
* @returns {jQuery Deferred} resolves with pages array
*/
function render() {
var deferred = $.Deferred();
pages = [];
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
var pageHeight = sizeConfig.height;
var contentAreaHeight = pageHeight - (config.contentPadding * 2);
var cleanHtml = preprocessContent(postData.content_html);
var blocks = parseHtmlToBlocks(cleanHtml);
// 先渲染内容流(DOM) - 用于实测高度
renderContentFlow(blocks);
// 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(
'<span>' + pages[i].pageNum + '</span>',
'<span class="page-number-indicator">' + (i + 1) + '/' + totalPages + '</span>'
);
}
}
// 渲染缩略图(右侧)- 异步截图
renderThumbnails(sizeConfig).then(function () {
deferred.resolve(pages);
}).catch(function (err) {
deferred.reject(err);
});
});
return deferred.promise();
}
/**
* 预处理HTML内容
*/
function preprocessContent(html) {
if (!html) return '';
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 清除空段落
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
// 处理图片: 保留原始尺寸到data属性, 强制 max-width:100%
html = html.replace(/<img([^>]*?)>/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 '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:10px 0;">';
});
return html;
}
/**
* 将HTML解析为块级元素数组
* 每个块: { type, html, estimatedHeight }
*/
function parseHtmlToBlocks(html) {
var blocks = [];
if (!html) return blocks;
var $temp = $('<div>').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>' + p + '</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 '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;
}
block.$img = $el;
break;
case 'ul':
case 'ol':
block.estimatedHeight = estimateListHeight($el);
break;
case 'blockquote':
block.estimatedHeight = estimateBlockquoteHeight($el);
break;
case 'p':
default:
block.type = 'p';
block.estimatedHeight = estimateTextHeight($el.text(), config.fontSize);
break;
}
blocks.push(block);
});
return blocks;
}
// ===== 高度估算 =====
/**
* 获取内容区可用宽度 (540 - 40px padding)
*/
function getContentWidth() {
return 500;
}
/**
* 估算文本高度
* @param {string} text
* @param {number} fontSize
* @returns {number} px
*/
function estimateTextHeight(text, fontSize) {
var charCount = text.length;
if (charCount === 0) return 0;
var charsPerLine = Math.floor(getContentWidth() / (fontSize * 1.0));
if (charsPerLine < 1) charsPerLine = 1;
var lines = Math.ceil(charCount / charsPerLine);
return lines * fontSize * 1.8 + 10; // 1.8行高 + 10px段落间距
}
/**
* 估算标题高度
*/
function estimateHeadingHeight(tag, text) {
var sizeMap = { h1: 28, h2: 24, h3: 20, h4: 18, h5: 16, h6: 14 };
var hSize = sizeMap[tag] || 20;
var charsPerLine = Math.floor(getContentWidth() / (hSize * 1.0));
if (charsPerLine < 1) charsPerLine = 1;
var lines = Math.ceil(text.length / charsPerLine);
return lines * hSize * 1.4 + 20; // heading行高1.4 + 20px上下间距
}
/**
* 估算图片高度
* 默认按内容区宽度的60%估算
*/
function estimateImageHeight($img) {
var w = parseInt($img.attr('width'), 10);
var h = parseInt($img.attr('height'), 10);
if (w > 0 && h > 0) {
// 按比例缩放到内容宽度
var ratio = getContentWidth() / w;
return Math.round(h * ratio) + 10;
}
// 无尺寸信息时默认300px
return 310;
}
/**
* 估算列表高度
*/
function estimateListHeight($list) {
var items = $list.find('li').length;
return items * (config.fontSize * 1.8 + 5) + 15;
}
/**
* 估算引用块高度
*/
function estimateBlockquoteHeight($bq) {
var text = $bq.text();
return estimateTextHeight(text, config.fontSize) + 10;
}
/**
* 估算表格高度 (已废弃 - DOM实测替代)
* @deprecated 使用 measureBlockHeights() 替代
*/
function estimateTableHeight($table) {
// DOM实测将覆盖此值返回0作为占位
return 0;
}
// ===== 分页核心算法 =====
/**
* 将块级元素按高度累加,超过可用高度时分页
* 支持 <hr> 强制分页和超大块拆分(超长文本拆成多段)
*/
function paginateContent(blocks, contentAreaHeight, sizeConfig) {
var contentPages = [];
var currentPageBlocks = [];
var currentHeight = 0;
var pageNumber = 1;
for (var i = 0; i < blocks.length; i++) {
var block = blocks[i];
// 强制分页标记
if (block.type === 'page-break') {
// 结束当前页
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
continue; // 跳过这个 page-break 块本身
}
// 超大块处理:单个块超过整页高度时需要拆分
if (block.estimatedHeight > contentAreaHeight) {
// 先把当前页已有的内容推出去
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
// 尝试拆分超大块
var splitBlocks = splitOversizedBlock(block, contentAreaHeight);
for (var s = 0; s < splitBlocks.length; s++) {
var sb = splitBlocks[s];
if (currentHeight + sb.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
currentPageBlocks.push(sb);
currentHeight += sb.estimatedHeight;
}
continue;
}
// 正常块:判断是否需要换页
if (currentHeight + block.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, false
));
currentPageBlocks = [];
currentHeight = 0;
pageNumber++;
}
currentPageBlocks.push(block);
currentHeight += block.estimatedHeight;
}
// 最后一页
if (currentPageBlocks.length > 0) {
contentPages.push(generateContentPage(
currentPageBlocks, pageNumber, sizeConfig, true
));
}
return contentPages;
}
/**
* 拆分超大块(超长段落文本拆成多段)
* @param {Object} block - 块级元素
* @param {number} pageHeight - 可用内容高度
* @returns {Array} 拆分后的块数组
*/
function splitOversizedBlock(block, pageHeight) {
// 图片块不拆分,保留原样(会被截断但保持完整)
if (block.type === 'img') {
return [block];
}
// 表格块不拆分,保留原样
if (block.type === 'table') {
return [block];
}
// 文本类块:按句子拆分
var text = '';
var wrapperTag = 'p';
if (block.type === 'blockquote') {
text = $(block.html).text();
wrapperTag = 'blockquote';
} else {
text = $(block.html).text();
}
if (!text || text.length === 0) {
return [block];
}
// 按句子拆分:句号、问号、叹号、换行
var sentences = text.split(/(?<=[。!?\n])/);
if (sentences.length <= 1) {
// 无法按句子拆分,按固定字符数拆分
sentences = [];
var chunkSize = Math.floor(getContentWidth() / config.fontSize) *
Math.floor(pageHeight / (config.fontSize * 1.8));
if (chunkSize < 10) chunkSize = 10;
for (var ci = 0; ci < text.length; ci += chunkSize) {
sentences.push(text.substring(ci, ci + chunkSize));
}
}
var result = [];
for (var j = 0; j < sentences.length; j++) {
var s = sentences[j].trim();
if (!s) continue;
var h = estimateTextHeight(s, config.fontSize);
result.push({
type: wrapperTag === 'blockquote' ? 'blockquote' : 'p',
html: '<' + wrapperTag + '>' + s + '</' + wrapperTag + '>',
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 = '<div class="phone-image-page page-cover';
if (hasCover) {
html += ' has-cover-image';
} else {
html += ' no-cover-image';
}
html += '" style="width:' + sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
if (hasCover) {
html += '<img class="cover-image" src="' + postData.poster + '" alt="">';
html += '<div class="cover-title">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
}
// 封面文案(有封面图)
if (postData.coverText) {
html += '<div style="font-size:14px;color:#1890ff;margin-top:8px;">' + escapeHtml(postData.coverText) + '</div>';
}
html += '<div class="cover-meta">';
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 += '</div>';
} else {
// 无封面图: 大标题+摘要+装饰线条排版
html += '<div class="cover-decor-line cover-decor-top"></div>';
html += '<div class="cover-no-img-content">';
html += '<div class="cover-no-img-title">' + escapeHtml(postData.title) + '</div>';
html += '<div class="cover-decor-divider"></div>';
if (postData.desc) {
html += '<div class="cover-no-img-desc">' + escapeHtml(postData.desc) + '</div>';
}
// 封面文案(无封面图)
if (postData.coverText) {
html += '<div style="font-size:14px;color:#1890ff;margin-top:10px;padding:0 30px;word-break:break-word;">' + escapeHtml(postData.coverText) + '</div>';
}
html += '<div class="cover-no-img-meta">';
if (postData.category_name) {
html += '<span>' + escapeHtml(postData.category_name) + '</span>';
}
if (postData.author_name) {
html += '<span>' + escapeHtml(postData.author_name) + '</span>';
}
if (postData.create_time) {
html += '<span>' + postData.create_time + '</span>';
}
html += '</div>';
html += '</div>';
html += '<div class="cover-decor-line cover-decor-bottom"></div>';
}
html += '</div>';
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 = '<div class="phone-image-page page-body' + valignClass + '" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
// 页头(仅第一页内容页显示标题)
if (pageNum === 1) {
html += '<div class="page-header">';
html += '<div class="page-title">' + escapeHtml(postData.title) + '</div>';
html += '</div>';
}
// 正文内容区
html += '<div class="page-content">';
for (var i = 0; i < blocks.length; i++) {
html += blocks[i].html;
}
html += '</div>';
// 页脚 - 占位render() 中会替换页码
html += '<div class="page-footer">';
html += '<span>' + escapeHtml(postData.author_name || '') + '</span>';
html += '<span>' + pageNum + '</span>';
html += '</div>';
html += '</div>';
return { type: 'content', html: html, pageNum: pageNum };
}
/**
* 生成尾页/总结页HTML
*/
function generateSummaryPage(sizeConfig, totalPages) {
var html = '<div class="phone-image-page page-summary" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
html += '<div class="summary-title">感谢阅读</div>';
html += '<div class="summary-text">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="summary-text" style="font-size:12px;">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="summary-footer">';
html += '共 ' + totalPages + ' 页';
if (postData.author_name) {
html += ' | ' + escapeHtml(postData.author_name);
}
html += '</div>';
html += '</div>';
return { type: 'summary', html: html };
}
// ===== DOM渲染 =====
/**
* 将 #render-staging 中的代码块转为图片
* 使用 prismjs 高亮 + html2canvas 截图替换
* @returns {jQuery Deferred} resolves when all code blocks are converted
*/
function convertCodeBlocks() {
var deferred = $.Deferred();
var $staging = $('#render-staging');
// 检查 Prism 是否可用
if (typeof Prism === 'undefined') {
deferred.resolve();
return deferred.promise();
}
// 给没有语言类的代码块添加默认语言
$staging.find('pre > code:not([class*="language-"])').addClass('language-plaintext');
// 高亮所有代码块
$staging.find('pre code').each(function () {
Prism.highlightElement(this);
});
// 逐个截图替换,每次重新查找避免 replaceWith 后索引失效
function convertNext() {
var $pre = $staging.find('pre').first();
if ($pre.length === 0) {
deferred.resolve();
return;
}
html2canvas($pre[0], {
scale: 2,
useCORS: true,
backgroundColor: '#f5f5f5',
logging: false
}).then(function (canvas) {
var imgData = canvas.toDataURL('image/jpeg', 0.92);
var $img = $('<img>');
$img.attr('src', imgData);
$img.css({
maxWidth: '100%',
height: 'auto',
display: 'block',
margin: '10px 0'
});
$pre.replaceWith($img);
convertNext();
}).catch(function () {
// 截图失败则跳过当前元素继续处理下一个
$pre.remove();
convertNext();
});
}
convertNext();
return deferred.promise();
}
/**
* 将 #render-staging 中的表格转为图片
* 使用 html2canvas 截图替换(与 convertCodeBlocks 模式相同)
* @returns {jQuery Deferred} resolves when all tables are converted
*/
function convertTables() {
var deferred = $.Deferred();
var $staging = $('#render-staging');
// 逐个截图替换,每次重新查找避免 replaceWith 后索引失效
function convertNext() {
var $table = $staging.find('.page-content table').first();
if ($table.length === 0) {
deferred.resolve();
return;
}
html2canvas($table[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
logging: false
}).then(function (canvas) {
var imgData = canvas.toDataURL('image/jpeg', 0.92);
var $img = $('<img>');
$img.attr('src', imgData);
$img.css({
maxWidth: '100%',
height: 'auto',
display: 'block',
margin: '10px 0'
});
$table.replaceWith($img);
convertNext();
}).catch(function () {
// 截图失败则跳过当前元素继续处理下一个
$table.remove();
convertNext();
});
}
convertNext();
return deferred.promise();
}
// ===== 纯图片页优化 =====
/**
* 检测staging区域中的页面元素是否为纯图片页
* 纯图片页: .page-body 内 .page-content 只含1个img且无文字内容
* @param {jQuery} $pageElem - .phone-image-page 元素
* @returns {boolean}
*/
function isPureImagePage($pageElem) {
// 仅内容页
if (!$pageElem.hasClass('page-body')) return false;
var $content = $pageElem.find('.page-content');
if (!$content.length) return false;
// 检查是否有文字内容(排除纯空白文本节点)
var textContent = $content.text().replace(/\s/g, '');
if (textContent.length > 0) return false;
// 检查img数量: 必须恰好1个img
var $imgs = $content.find('img');
if ($imgs.length !== 1) return false;
// img必须有有效src
var src = $imgs.first().attr('src');
return src && src.length > 0;
}
/**
* 从纯图片页元素中获取img的src
* @param {jQuery} $pageElem
* @returns {string}
*/
function getPureImageSrc($pageElem) {
var $img = $pageElem.find('.page-content img').first();
return $img.attr('src') || '';
}
/**
* 将原始图片绘制到canvas用于高质量保存路径
* @param {string} src - 图片src
* @param {number} width - 目标宽度
* @param {number} height - 目标高度
* @returns {jQuery Deferred} resolves with canvas
*/
function renderPureImageToCanvas(src, width, height) {
var deferred = $.Deferred();
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
deferred.reject('Image has zero dimensions');
return;
}
var canvas = document.createElement('canvas');
canvas.width = width * 2; // scale:2 高质量
canvas.height = height * 2;
var ctx = canvas.getContext('2d');
// 白色背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 缩放绘制
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
deferred.resolve(canvas);
};
img.onerror = function () {
deferred.reject('Image load failed');
};
img.src = src;
return deferred.promise();
}
/**
* 共享截图核心逻辑 - 将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 = $('<div class="phone-image-container ' + sizeClass + '">');
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.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);
});
});
});
});
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.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 = $('<div class="preview-thumb-item" data-page-index="' + i + '">');
$item.css('width', thumbWidth + 'px');
var $img = $('<img>');
$img.attr('src', dataUrls[i]);
$img.css('width', thumbWidth + 'px');
$item.append($img);
// 页码
var $pageNum = $('<span class="preview-thumb-page-num">' + (i + 1) + '/' + dataUrls.length + '</span>');
$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 = $('<button class="thumb-alignment-toggle' + (isActiveCenter ? ' active-center' : '') + '" data-page-index="' + i + '" data-page-num="' + pageNum + '">' +
(isActiveCenter ? '\u2195' : '\u2191') +
'</button>');
$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
});
}
/**
* 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 = $('<div class="page-break-marker">' +
'<span class="break-marker-label">-- 分页标记 --</span>' +
'<button class="remove-break-btn" data-index="' + i + '" title="删除分页">&times;</button>' +
'</div>');
$flow.append($marker);
continue;
}
// 内容块
var $block = $('<div class="content-flow-block" data-index="' + i + '">' + block.html + '</div>');
$flow.append($block);
// 块与块之间的分页插入区域(不在最后一个块之后添加)
if (i < blocks.length - 1) {
var $inserter = $('<div class="break-inserter" data-after-index="' + i + '">' +
'<button class="break-inserter-btn" title="插入分页">+</button>' +
'</div>');
$flow.append($inserter);
}
}
}
/**
* 在指定位置插入分页标记
* 修改 postData.content_html在对应位置插入 <hr>
* @param {number} blockIndex - 在哪个块之后插入 (对应 break-inserter 的 data-after-index)
*/
function insertPageBreak(blockIndex) {
var cleanHtml = preprocessContent(postData.content_html);
var $temp = $('<div>').html(cleanHtml);
var children = $temp.children();
// 在 blockIndex 位置的元素之后插入 <hr>
if (blockIndex >= 0 && blockIndex < children.length) {
$(children[blockIndex]).after('<hr>');
} else {
$temp.append('<hr>');
}
postData.content_html = $temp.html();
// 重新渲染
render();
}
/**
* 删除指定位置的分页标记
* @param {number} blockIndex - 分页标记的 data-index
*/
function removePageBreak(blockIndex) {
// 统计这是第几个 page-break
var cleanHtml = preprocessContent(postData.content_html);
var $temp = $('<div>').html(cleanHtml);
var hrElements = $temp.find('hr');
// 在 blocks 数组中找到这个 page-break 是第几个
var breakOrder = 0;
var currentBlocks = parseHtmlToBlocks(cleanHtml);
for (var i = 0; i < currentBlocks.length && i < blockIndex; i++) {
if (currentBlocks[i].type === 'page-break') breakOrder++;
}
if (breakOrder < hrElements.length) {
$(hrElements[breakOrder]).remove();
postData.content_html = $temp.html();
render();
}
}
// ===== 图片生成 =====
/**
* 逐页截图生成图片(使用隐藏渲染区域高质量截图)
* @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas)
* @returns {Promise} jQuery Deferred, resolves with array of canvas objects
*/
function generateImages(onProgress) {
var deferred = $.Deferred();
if (pages.length === 0) {
deferred.reject('没有可渲染的页面');
return deferred.promise();
}
if (onProgress) onProgress(0, pages.length, null);
capturePagesFromStaging().then(function (canvases) {
if (onProgress) onProgress(canvases.length, canvases.length, null);
deferred.resolve(canvases);
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 将canvas转为base64并保存到服务端
* @param {number} postId 文章ID
* @param {Object} saveConfig 配置信息
* @param {Function} [onProgress] 生成进度回调 function(current, total, canvas)
* @returns {Promise} jQuery Deferred
*/
function saveImages(postId, saveConfig, onProgress) {
var deferred = $.Deferred();
capturePagesFromStaging().then(function (canvases) {
if (onProgress) onProgress(canvases.length, canvases.length, null);
var pagesData = [];
for (var i = 0; i < canvases.length; i++) {
pagesData.push(canvases[i].toDataURL('image/jpeg', 0.92));
}
$.ajax({
url: '/index.php/admin/post/savePostOutput',
type: 'POST',
data: JSON.stringify({
post_id: postId,
output_type: 'phone_image',
config: saveConfig || config,
content_html: postData.content_html,
pages: pagesData
}),
contentType: 'application/json',
success: function (result) {
if (result.code === 0) {
deferred.resolve(result.data);
} else {
deferred.reject(result.msg || '保存失败');
}
},
error: function (xhr) {
deferred.reject('网络错误: ' + xhr.statusText);
}
});
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 保存排版配置到服务端(不生成图片)
* @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();
}
/**
* 将生成的canvas数组显示为缩略图
* @param {Array} canvases canvas对象数组
* @param {string} containerSelector 容器选择器
*/
function showGeneratedThumbnails(canvases, containerSelector) {
var $container = $(containerSelector);
$container.empty();
for (var i = 0; i < canvases.length; i++) {
var thumb = canvases[i].toDataURL('image/jpeg', 0.5);
var $item = $('<div class="phone-thumb-item" data-index="' + i + '">' +
'<img src="' + thumb + '" alt="第' + (i + 1) + '页">' +
'<span class="phone-thumb-page">' + (i + 1) + '</span>' +
'</div>');
$container.append($item);
}
}
// ===== 配置切换 =====
/**
* 切换尺寸
*/
function switchSize(name) {
config.size = name;
}
// ===== 逐页对齐 =====
/**
* 设置指定页的对齐方式
* @param {number} pageNum 页码(1-based)
* @param {string} align 'top'|'center'
*/
function setPageAlignment(pageNum, align) {
if (!config.pageAlignments) {
config.pageAlignments = {};
}
config.pageAlignments[pageNum] = align;
}
/**
* 导出内容流为长图
*/
function exportLongImage() {
var deferred = $.Deferred();
var $flow = $('#content-flow');
if (!$flow.length) {
deferred.reject('内容流容器不存在');
return deferred.promise();
}
// 临时隐藏交互元素
$flow.find('.break-inserter').hide();
$flow.find('.page-break-marker').hide();
// 获取内容流的实际高度
var flowWidth = $flow.outerWidth();
var flowHeight = $flow[0].scrollHeight;
html2canvas($flow[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: flowWidth,
height: flowHeight,
logging: false
}).then(function (canvas) {
// 恢复交互元素
$flow.find('.break-inserter').show();
$flow.find('.page-break-marker').show();
// 触发下载
var link = document.createElement('a');
link.download = 'phone-image-long-' + Date.now() + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
deferred.resolve(canvas);
}).catch(function (err) {
// 恢复交互元素
$flow.find('.break-inserter').show();
$flow.find('.page-break-marker').show();
deferred.reject('长图导出失败: ' + err);
});
return deferred.promise();
}
// ===== 工具方法 =====
/**
* HTML转义
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
/**
* 获取当前配置
*/
function getConfig() {
return $.extend({}, config);
}
/**
* 获取分页结果
*/
function getPages() {
return pages.slice();
}
// ===== 公开API =====
return {
init: init,
render: render,
generateImages: generateImages,
saveImages: saveImages,
saveConfig: saveConfig,
showGeneratedThumbnails: showGeneratedThumbnails,
switchSize: switchSize,
getConfig: getConfig,
getPages: getPages,
setPageAlignment: setPageAlignment,
renderContentFlow: renderContentFlow,
insertPageBreak: insertPageBreak,
removePageBreak: removePageBreak,
exportLongImage: exportLongImage
};
})();