Files
ulthon_information/public/static/js/phone-image.js
augushong bf32bce922 feat(typesetting): 页面品牌标识 - 数据链路+渲染函数+分页修正
- Post.php: phoneImage()传递siteName/siteLogo到模板
- phone_image.html: postData增加siteName/siteLogo字段
- phone-image.js: 新增generateBrandHeader()+preloadBrandLogo()
- phone-image.js: 3个页面生成函数调用品牌头部
- phone-image.js: contentAreaHeight扣减BRAND_HEADER_HEIGHT(36px)
- logo预转base64规避html2canvas CORS,过滤默认头像
2026-05-17 11:12:56 +08:00

1729 lines
63 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.

/**
* 操作日志面板模块
* 提供实时日志显示,替代 layer.load() 遮罩
*/
var PhoneImageLogPanel = (function () {
var $panel = null;
var $body = null;
var MAX_ENTRIES = 500;
var entryCount = 0;
function formatTime() {
var d = new Date();
var h = (d.getHours() < 10 ? '0' : '') + d.getHours();
var m = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes();
var s = (d.getSeconds() < 10 ? '0' : '') + d.getSeconds();
return h + ':' + m + ':' + s;
}
function log(msg, level) {
if (!$body) return;
level = level || 'info';
var entryHtml = '<div class="log-entry log-' + level + '">' +
'<span class="log-time">' + formatTime() + '</span>' +
'<span class="log-msg">' + msg + '</span>' +
'</div>';
$body.append(entryHtml);
entryCount++;
if (entryCount > MAX_ENTRIES) {
$body.children().first().remove();
entryCount--;
}
$body.scrollTop($body[0].scrollHeight);
}
function clear() {
if (!$body) return;
$body.empty();
entryCount = 0;
}
function toggle() {
if (!$panel) return;
$panel.toggleClass('collapsed');
}
function collapse() {
if (!$panel) return;
$panel.addClass('collapsed');
}
function expand() {
if (!$panel) return;
$panel.removeClass('collapsed');
}
function init(container) {
var panelHtml = '<div class="log-panel" id="log-panel">' +
'<div class="log-panel-header">' +
'<span style="font-size:12px;color:#ccc;">操作日志</span>' +
'<div class="log-panel-actions">' +
'<button type="button" id="log-btn-clear" style="background:transparent;border:1px solid #555;color:#aaa;padding:1px 8px;cursor:pointer;border-radius:3px;font-size:11px;">清空</button>' +
'<button type="button" id="log-btn-toggle" style="background:transparent;border:1px solid #555;color:#aaa;padding:1px 8px;cursor:pointer;border-radius:3px;font-size:11px;">收起</button>' +
'</div>' +
'</div>' +
'<div class="log-panel-body"></div>' +
'</div>';
$(container).append(panelHtml);
$panel = $('#log-panel');
$body = $panel.find('.log-panel-body');
$('#log-btn-clear').on('click', function () { clear(); });
$('#log-btn-toggle').on('click', function () {
toggle();
$(this).text($panel.hasClass('collapsed') ? '展开' : '收起');
});
}
return { init: init, log: log, clear: clear, toggle: toggle, collapse: collapse, expand: expand };
})();
/**
* PhoneImageEngine - 手机图片排版引擎
*
* 将文章HTML内容自动分页并渲染为手机尺寸图片
* 依赖: jQuery, html2canvas (项目已有)
*
* 渲染宽度540px, html2canvas scale=2 输出1080px
* 小红书: 540x720 (输出1080x1440)
* 抖音: 540x960 (输出1080x1920)
*/
var PhoneImageEngine = (function () {
// ===== 配置 =====
var config = {
size: 'xiaohongshu',
watermark: '',
pageAlignments: {}, // key=页码(1-based), value='top'|'center'
sizes: {
xiaohongshu: { width: 540, height: 720 },
douyin: { width: 540, height: 960 }
},
contentPadding: 20,
fontScale: 1,
tableFontScale: 1
};
// ===== 文章数据 =====
var postData = {
id: 0,
title: '',
desc: '',
coverText: '', // 封面文案
content_html: '',
poster: '',
author_name: '',
site_name: '',
site_logo: '',
create_time: '',
category_name: ''
};
// ===== 分页结果 =====
var pages = [];
/**
* 确保隐藏渲染区域存在
*/
function ensureStaging() {
if ($('#render-staging').length === 0) {
$('<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 || '';
postData.site_name = options.siteName || '';
postData.site_logo = options.siteLogo || '';
if (userConfig) {
$.extend(config, userConfig);
}
applyFontScale();
preloadBrandLogo();
}
/**
* 应用字号缩放到CSS变量
*/
function applyFontScale() {
var scale = config.fontScale || 1;
var tableScale = config.tableFontScale || 1;
var preview = document.getElementById('render-preview');
if (preview) {
preview.style.setProperty('--pi-font-scale', scale);
preview.style.setProperty('--pi-table-font-scale', tableScale);
}
var staging = document.getElementById('render-staging');
if (staging) {
staging.style.setProperty('--pi-font-scale', scale);
staging.style.setProperty('--pi-table-font-scale', tableScale);
}
}
/**
* 渲染排版预览 - 生成分页后的HTML
* 管线: editorHtml -> parseHtmlToBlocks -> captureEditorBlocks -> generatePages -> renderThumbnails
* 带并发锁:防止多次 render 同时执行造成数据混乱
* @returns {jQuery Deferred} resolves with pages array
*/
function render() {
var deferred = $.Deferred();
// 防止并发渲染
if (render._locked) {
// 标记有待处理的渲染请求,当前渲染完成后会自动重试
PhoneImageLogPanel.log('渲染进行中,跳过本次请求', 'warn');
render._pending = true;
return deferred.reject('rendering').promise();
}
render._locked = true;
PhoneImageLogPanel.log('开始渲染...', 'info');
pages = [];
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
var pageHeight = sizeConfig.height;
var contentAreaHeight = pageHeight - (config.contentPadding * 2);
var brandHeaderOffset = (postData.site_name || postData.site_logo) ? BRAND_HEADER_HEIGHT : 0;
contentAreaHeight -= brandHeaderOffset;
// 从预览区读取已预处理的内容
syncPreview();
PhoneImageLogPanel.log('同步预览区内容', 'info');
var previewEl = document.getElementById('render-preview');
// 等待预览区内所有图片加载完成,否则 getBoundingClientRect 测高为0
PhoneImageLogPanel.log('等待图片加载...', 'info');
waitForImages(previewEl).then(function () {
var cleanHtml = previewEl ? previewEl.innerHTML : '';
var blocks = parseHtmlToBlocks(cleanHtml);
PhoneImageLogPanel.log('解析内容: ' + blocks.length + ' 个块', 'info');
// 空内容检测
if (blocks.length === 0) {
$('#paginated-preview').html('<div style="text-align:center;padding:60px 20px;color:#999;">文章正文内容为空</div>');
render._locked = false;
return deferred.resolve([]).promise();
}
// 封面页
pages.push(generateCoverPage(sizeConfig));
PhoneImageLogPanel.log('生成封面页', 'info');
// 内容分页
PhoneImageLogPanel.log('分页计算中...', 'info');
captureEditorBlocks(cleanHtml, blocks, contentAreaHeight, sizeConfig).then(function(contentPages) {
pages = pages.concat(contentPages);
// 尾页
pages.push(generateSummaryPage(sizeConfig, pages.length));
PhoneImageLogPanel.log('生成尾页', 'info');
// 页码 N/M
var totalPages = pages.length;
for (var i = 0; i < pages.length; i++) {
if (pages[i].type === 'content') {
pages[i].html = pages[i].html.replace(
'<span>' + pages[i].pageNum + '</span>',
'<span class="page-number-indicator">' + (i + 1) + '/' + totalPages + '</span>'
);
}
}
// 渲染缩略图
renderThumbnails(sizeConfig).then(function() {
PhoneImageLogPanel.log('渲染完成,共 ' + pages.length + ' 页', 'success');
render._locked = false;
if (render._pending) {
render._pending = false;
render().catch(function() {});
}
deferred.resolve(pages);
}).catch(function(err) {
PhoneImageLogPanel.log('渲染失败: ' + err, 'error');
render._locked = false;
deferred.reject(err);
});
}).catch(function(err) {
PhoneImageLogPanel.log('分页计算失败: ' + err, 'error');
render._locked = false;
deferred.reject(err);
});
});
return deferred.promise();
}
/**
* 预处理HTML内容
*/
function preprocessContent(html) {
if (!html) return '';
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 移除不支持的媒体标签
html = html.replace(/<iframe[\s\S]*?<\/iframe>/gi, '[媒体内容]');
html = html.replace(/<video[\s\S]*?<\/video>/gi, '[媒体内容]');
html = html.replace(/<svg[\s\S]*?<\/svg>/gi, '');
html = html.replace(/<embed[^>]*>/gi, '');
html = html.replace(/<object[\s\S]*?<\/object>/gi, '');
// 清除空段落
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
// 移除内联font-size,让CSS变量控制字号
html = html.replace(/\s*font-size\s*:\s*[^;'"<>]+;?/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写入预览区
*/
function syncPreview() {
var html = window.phoneImageEditor ? window.phoneImageEditor.getHtml() : postData.content_html;
var cleanHtml = preprocessContent(html);
$('#render-preview').html(cleanHtml);
}
/**
* 等待容器内所有图片加载完成
* 图片未加载时 getBoundingClientRect() 高度为0导致分页计算错误
* @param {HTMLElement} containerEl
* @returns {jQuery Deferred}
*/
function waitForImages(containerEl) {
var deferred = $.Deferred();
var imgs = containerEl ? containerEl.querySelectorAll('img') : [];
if (imgs.length === 0) {
return deferred.resolve().promise();
}
var loaded = 0;
var total = imgs.length;
var timer = setTimeout(function () {
// 超时保底3秒后强制继续
deferred.resolve();
}, 3000);
function checkDone() {
loaded++;
if (loaded >= total) {
clearTimeout(timer);
deferred.resolve();
}
}
for (var i = 0; i < imgs.length; i++) {
if (imgs[i].complete && imgs[i].naturalHeight > 0) {
checkDone();
} else {
imgs[i].onload = checkDone;
imgs[i].onerror = checkDone;
}
}
return deferred.promise();
}
/**
* 将HTML解析为块级元素数组
* 每个块: { type, html, estimatedHeight }
*/
function parseHtmlToBlocks(html) {
var blocks = [];
if (!html) return blocks;
var $temp = $('<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: 40
});
}
}
}
return blocks;
}
children.each(function () {
var $el = $(this);
var tagName = $el.prop('tagName').toLowerCase();
var block = {
type: tagName,
html: $el[0].outerHTML
};
switch (tagName) {
case 'hr':
block.type = 'page-break';
block.html = '';
block.estimatedHeight = 0;
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
block.type = tagName;
block.estimatedHeight = 40;
break;
case 'table':
block.type = 'table';
block.html = $el[0].outerHTML;
block.estimatedHeight = 0;
block.needsMeasurement = true;
break;
case 'pre':
block.type = 'pre';
block.html = $el[0].outerHTML;
block.estimatedHeight = 0;
block.needsMeasurement = true;
break;
case 'img':
// 尝试从data属性计算比例高度, 否则标记需要DOM测量
var imgW = parseInt($el.attr('data-original-width'), 10);
var imgH = parseInt($el.attr('data-original-height'), 10);
if (imgW > 0 && imgH > 0) {
var imgRatio = 500 / imgW;
block.estimatedHeight = Math.round(imgH * imgRatio) + 20;
} else {
block.estimatedHeight = 0;
block.needsMeasurement = true;
}
break;
case 'ul':
case 'ol':
block.estimatedHeight = 40;
break;
case 'blockquote':
block.estimatedHeight = 40;
break;
case 'p':
default:
block.type = 'p';
block.estimatedHeight = 40;
break;
}
blocks.push(block);
});
return blocks;
}
// ===== wangeditor 集成层 =====
/**
* 获取 wangeditor 编辑区的子元素
* @returns {Array} DOM元素数组
*/
function getEditorChildren() {
var editorArea = document.querySelector('#editor-text-area [data-slate-editor]');
if (!editorArea) return [];
var children = editorArea.children;
// wangeditor v5: [data-slate-editor] 下可能有一层 div 包装
// 如果只有一个 child 且是 div则取其子元素
if (children.length === 1 && children[0].tagName.toLowerCase() === 'div') {
return Array.prototype.slice.call(children[0].children);
}
return Array.prototype.slice.call(children);
}
/**
* 从 wangeditor 内容构建分页数据
* 完整实现从编辑器DOM截图测高 -> 按分割线分组 -> 按高度分页 -> 生成staging HTML
* @param {string} html - 编辑器HTML内容
* @param {Array} blocks - parseHtmlToBlocks 解析后的块数组
* @param {number} contentAreaHeight - 内容区可用高度(px)
* @param {Object} sizeConfig - {width, height}
* @returns {jQuery Deferred} resolves with contentPages array
*/
function captureEditorBlocks(html, blocks, contentAreaHeight, sizeConfig) {
var deferred = $.Deferred();
// 按 page-break 分组
var groups = [];
var currentGroup = { blocks: [], domIndices: [] };
var domIdx = 0;
for (var i = 0; i < blocks.length; i++) {
if (blocks[i].type === 'page-break') {
if (currentGroup.blocks.length > 0) groups.push(currentGroup);
currentGroup = { blocks: [], domIndices: [] };
domIdx++;
continue;
}
currentGroup.blocks.push(blocks[i]);
currentGroup.domIndices.push(domIdx);
domIdx++;
}
if (currentGroup.blocks.length > 0) groups.push(currentGroup);
if (groups.length === 0) {
deferred.resolve([]);
return deferred.promise();
}
// 从 #render-preview 获取子元素(已预处理的干净内容)
var previewEl = document.getElementById('render-preview');
if (!previewEl) {
// fallback: 如果预览区不存在给所有block设默认高度
for (var k = 0; k < blocks.length; k++) {
if (!blocks[k].estimatedHeight) blocks[k].estimatedHeight = 40;
}
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
deferred.resolve(contentPages);
return deferred.promise();
}
var previewChildren = Array.prototype.slice.call(previewEl.children);
// 逐 block 精确测高(包含 margin否则分页偏小导致内容溢出
for (var gi = 0; gi < groups.length; gi++) {
var group = groups[gi];
for (var j = 0; j < group.blocks.length; j++) {
var di = group.domIndices[j];
if (di < previewChildren.length) {
var el = previewChildren[di];
var rect = el.getBoundingClientRect();
var cs = window.getComputedStyle(el);
var mt = parseFloat(cs.marginTop) || 0;
var mb = parseFloat(cs.marginBottom) || 0;
group.blocks[j].estimatedHeight = Math.round(rect.height + mt + mb);
}
}
}
// 给尚未设置高度的 block 设默认值
for (var k = 0; k < blocks.length; k++) {
if (!blocks[k].estimatedHeight) {
blocks[k].estimatedHeight = 40;
}
}
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
deferred.resolve(contentPages);
return deferred.promise();
}
// ===== 分页核心算法 =====
/**
* 将块级元素按高度累加,超过可用高度时分页
* 支持 <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} 拆分后的块数组
*/
/**
* 将超大表格按行拆分为多个表格块
* 保留表头thead或第一行tr在每个拆分后的表格中
* @param {Object} block - 表格块 { type:'table', html, estimatedHeight }
* @param {number} pageHeight - 可用内容高度
* @returns {Array} 拆分后的块数组
*/
function splitTableByRows(block, pageHeight) {
var $table = $(block.html);
var $allRows = $table.find('tr');
if ($allRows.length <= 1) return [block];
// 提取表头thead 或第一行)
var $thead = $table.find('thead');
var headerHtml = '';
var dataStartIdx = 0;
if ($thead.length > 0) {
headerHtml = '<thead>' + $thead.html() + '</thead>';
dataStartIdx = 0; // 数据行从 thead 之后开始
// 检查 tbody
var $tbody = $table.find('tbody');
if ($tbody.length > 0) {
$allRows = $tbody.find('tr');
}
} else {
// 第一行作为表头
headerHtml = '<tr>' + $allRows.eq(0).html() + '</tr>';
dataStartIdx = 1;
}
// 估算每行高度
var rowCount = $allRows.length - dataStartIdx;
if (rowCount <= 0) return [block];
var perRowHeight = block.estimatedHeight / ($allRows.length);
// 表头高度
var headerHeight = perRowHeight;
// 每页能放的数据行数
var rowsPerPage = Math.floor((pageHeight - headerHeight) / perRowHeight);
if (rowsPerPage < 1) rowsPerPage = 1;
// 保留原始表格属性class, style 等)
var tableAttrs = '';
var attrs = $table[0].attributes;
for (var a = 0; a < attrs.length; a++) {
if (attrs[a].name !== 'class' || attrs[a].value.indexOf('w-e') === -1) {
tableAttrs += ' ' + attrs[a].name + '="' + attrs[a].value + '"';
}
}
var result = [];
for (var r = dataStartIdx; r < $allRows.length; r += rowsPerPage) {
var chunkHtml = '<table' + tableAttrs + '>';
chunkHtml += headerHtml;
chunkHtml += '<tbody>';
var end = Math.min(r + rowsPerPage, $allRows.length);
for (var ri = r; ri < end; ri++) {
chunkHtml += '<tr>' + $allRows.eq(ri).html() + '</tr>';
}
chunkHtml += '</tbody></table>';
var chunkRows = end - r + (headerHtml ? 1 : 0);
result.push({
type: 'table',
html: chunkHtml,
estimatedHeight: Math.round(chunkRows * perRowHeight)
});
}
return result.length > 0 ? result : [block];
}
function splitOversizedBlock(block, pageHeight) {
// 图片块不拆分,保留原样(会被截断但保持完整)
if (block.type === 'img') {
return [block];
}
// 表格块:按行拆分
if (block.type === 'table') {
return splitTableByRows(block, pageHeight);
}
// 代码块不拆分,保留原样
if (block.type === 'pre') {
return [block];
}
// 文本类块:按句子拆分
var text = '';
var wrapperTag = 'p';
if (block.type === 'blockquote') {
text = $(block.html).text();
wrapperTag = 'blockquote';
} else {
text = $(block.html).text();
}
if (!text || text.length === 0) {
return [block];
}
// 按句子拆分:句号、问号、叹号、换行(兼容不支持 lookbehind 的浏览器)
var effectiveFontSize = 14 * (config.fontScale || 1);
var parts = text.split(/([。!?\n])/);
var sentences = [];
var current = '';
for (var si = 0; si < parts.length; si++) {
current += parts[si];
if (/[。!?\n]/.test(parts[si]) && current.length > 0) {
sentences.push(current);
current = '';
}
}
if (current) sentences.push(current);
if (sentences.length <= 1) {
// 无法按句子拆分,按固定字符数拆分
sentences = [];
var chunkSize = Math.floor(500 / effectiveFontSize) *
Math.floor(pageHeight / (effectiveFontSize * 1.8));
if (chunkSize < 10) chunkSize = 10;
for (var ci = 0; ci < text.length; ci += chunkSize) {
sentences.push(text.substring(ci, ci + chunkSize));
}
}
var result = [];
for (var j = 0; j < sentences.length; j++) {
var s = sentences[j].trim();
if (!s) continue;
var lineChars = Math.floor(500 / effectiveFontSize);
if (lineChars < 1) lineChars = 1;
var lines = Math.ceil(s.length / lineChars);
var h = lines * effectiveFontSize * 1.8 + 10;
result.push({
type: wrapperTag === 'blockquote' ? 'blockquote' : 'p',
html: '<' + wrapperTag + '>' + s + '</' + 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;
}
// ===== 品牌头部 =====
var BRAND_HEADER_HEIGHT = 36;
var brandLogoBase64 = null;
function generateBrandHeader() {
if (!postData.site_name && !postData.site_logo) return '';
var html = '<div class="brand-header" style="height:' + BRAND_HEADER_HEIGHT + 'px;">';
if (postData.site_logo) {
var logoSrc = brandLogoBase64 || postData.site_logo;
html += '<img class="brand-logo" src="' + escapeHtml(logoSrc) + '" alt="" onerror="this.style.display=\'none\'">';
}
if (postData.site_name) {
html += '<span class="brand-name">' + escapeHtml(postData.site_name) + '</span>';
}
html += '</div>';
return html;
}
function preloadBrandLogo() {
if (!postData.site_logo) return;
if (postData.site_logo.indexOf('avatar.png') !== -1) {
postData.site_logo = '';
return;
}
try {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
var canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
brandLogoBase64 = canvas.toDataURL('image/png');
};
img.src = postData.site_logo;
} catch(e) {
// base64 conversion failed, use original URL
}
}
/**
* 生成封面页HTML
*/
function generateCoverPage(sizeConfig) {
var hasCover = hasValidPoster();
var html = '<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;">';
html += generateBrandHeader();
if (hasCover) {
html += '<img class="cover-image" src="' + escapeHtml(postData.poster) + '" alt="" onerror="this.style.display=\'none\'">';
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 class="cover-text-inline">' + 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 class="cover-text-inline cover-text-no-img">' + 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>';
}
// 水印
if (config.watermark) {
html += '<div class="page-watermark">' + escapeHtml(config.watermark) + '</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 = '';
if (alignment === 'center') {
valignClass = ' valign-center';
} else if (alignment === 'bottom') {
valignClass = ' valign-bottom';
}
var html = '<div class="phone-image-page page-body' + valignClass + '" style="width:' +
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
html += generateBrandHeader();
// 页头(仅第一页内容页显示标题)
if (pageNum === 1) {
html += '<div class="page-header">';
html += '<div class="page-title">' + escapeHtml(postData.title) + '</div>';
html += '</div>';
}
// 作者声明(所有内容页)
if (postData.author_name) {
html += '<div class="author-credit">文/' + escapeHtml(postData.author_name) + '</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>';
// 水印
if (config.watermark) {
html += '<div class="page-watermark">' + escapeHtml(config.watermark) + '</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 += generateBrandHeader();
html += '<div class="summary-title">感谢阅读</div>';
html += '<div class="summary-text">' + escapeHtml(postData.title) + '</div>';
if (postData.desc) {
html += '<div class="summary-text">' + escapeHtml(postData.desc) + '</div>';
}
html += '<div class="summary-footer">';
html += '共 ' + totalPages + ' 页';
if (postData.author_name) {
html += ' | ' + escapeHtml(postData.author_name);
}
html += '</div>';
// 水印
if (config.watermark) {
html += '<div class="page-watermark">' + escapeHtml(config.watermark) + '</div>';
}
html += '</div>';
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 = img.naturalWidth;
canvas.height = img.naturalHeight;
var ctx = canvas.getContext('2d');
// 白色背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 按原图尺寸绘制,不缩放
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
deferred.resolve(canvas);
};
img.onerror = function () {
deferred.reject('Image load failed');
};
img.src = src;
return deferred.promise();
}
/**
* 共享截图核心逻辑 - 将pages渲染到staging并逐页截图
* @param {Object} opts - { scale, outputCanvas, quality, sizeConfig }
* scale: 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, pageProgressCallback) {
var deferred = $.Deferred();
ensureStaging();
applyFontScale();
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.catch(function () {
// 字体加载失败不影响截图,继续执行
}).then(function () {
requestAnimationFrame(function () {
// html2canvas会跳过visibility:hidden的元素临时切换为可见
$staging.css({ visibility: 'visible' });
// 表格和代码块已在中间栏中转为图片,无需再次转换
runCaptureLoop($staging, opts, deferred, pageProgressCallback);
});
});
return deferred.promise();
}
/**
* 执行串行截图循环
* @param {jQuery} $staging - staging元素
* @param {Object} opts - 截图选项
* @param {jQuery Deferred} deferred - 外部deferred
*/
function runCaptureLoop($staging, opts, deferred, pageProgressCallback) {
var $pageElems = $staging.find('.phone-image-page');
if ($pageElems.length === 0) {
$staging.css({ visibility: 'hidden' });
if (opts.outputCanvas) {
deferred.reject('没有可渲染的页面');
} else {
deferred.resolve([]);
}
return;
}
var results = [];
var idx = 0;
var total = $pageElems.length;
function captureNext() {
if (idx >= total) {
$staging.empty();
$staging.css({ visibility: 'hidden' });
deferred.resolve(results);
return;
}
var $elem = $pageElems.eq(idx);
// 纯图片页优化
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);
if (opts.streaming && opts.onPageReady) opts.onPageReady(canvas, idx);
if (pageProgressCallback) pageProgressCallback(idx + 1, total);
idx++;
captureNext();
}).catch(function () {
// 加载失败回退到html2canvas
capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, function () { if (opts.streaming && opts.onPageReady) opts.onPageReady(results[results.length - 1], idx); if (pageProgressCallback) pageProgressCallback(idx + 1, total); idx++; captureNext(); });
});
return;
} else {
// 缩略图模式: 直接用src
results.push(pureSrc);
if (opts.streaming && opts.onPageReady) opts.onPageReady(pureSrc, idx);
if (pageProgressCallback) pageProgressCallback(idx + 1, total);
idx++;
captureNext();
return;
}
}
}
capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, function () { if (opts.streaming && opts.onPageReady) opts.onPageReady(results[results.length - 1], idx); 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();
// 流式渲染:先清空容器,再逐页追加缩略图
initThumbnails(sizeConfig);
doCapturePages({
scale: 1,
outputCanvas: false,
quality: 0.85,
sizeConfig: sizeConfig,
streaming: true,
onPageReady: function (dataUrl, pageIndex) {
appendThumbnail(dataUrl, pageIndex, sizeConfig, pages[pageIndex]);
}
}, function (current, total) {
PhoneImageLogPanel.log('截图: 第 ' + current + '/' + total + ' 页', 'info');
}).then(function (dataUrls) {
updateThumbnailPageNumbers(dataUrls.length);
deferred.resolve(pages);
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 初始化缩略图容器(流式渲染第一步:清空容器)
* @param {Object} sizeConfig
*/
function initThumbnails(sizeConfig) {
var $preview = $('#paginated-preview');
if (!$preview.length) return;
$preview.empty();
}
/**
* 追加单个缩略图到预览区(流式渲染:每页截图完成后立即追加)
* @param {string} dataUrl - base64 图片数据或图片src
* @param {number} pageIndex - 页面索引(0-based)
* @param {Object} sizeConfig
* @param {Object} pageData - 页面对象 { type, pageNum, ... }
*/
function appendThumbnail(dataUrl, pageIndex, sizeConfig, pageData) {
var $preview = $('#paginated-preview');
if (!$preview.length) return;
// 计算缩放比例:缩略图高度 = 容器高度 - 40(页码标签) - 40(上下padding)
var containerHeight = $preview.parent().height() || 700;
var scaleRatio = (containerHeight - 80) / sizeConfig.height;
var thumbWidth = Math.round(sizeConfig.width * scaleRatio);
var $item = $('<div class="preview-thumb-item" data-page-index="' + pageIndex + '">');
$item.css('width', thumbWidth + 'px');
var $img = $('<img class="preview-thumb-img">');
$img.attr('src', dataUrl);
$img.css('width', thumbWidth + 'px');
$item.append($img);
// 页码(初始只显示序号,全部完成后更新为 N/M
var $pageNum = $('<span class="preview-thumb-page-num">' + (pageIndex + 1) + '</span>');
$item.append($pageNum);
// 对齐下拉(仅内容页)
if (pageData && pageData.type === 'content') {
var pageNum = pageData.pageNum || (pageIndex);
var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top';
var $select = $('<select class="thumb-alignment-select" data-page-index="' + pageIndex + '" data-page-num="' + pageNum + '">' +
'<option value="top"' + (currentAlign === 'top' ? ' selected' : '') + '>置顶</option>' +
'<option value="center"' + (currentAlign === 'center' ? ' selected' : '') + '>居中</option>' +
'<option value="bottom"' + (currentAlign === 'bottom' ? ' selected' : '') + '>底部</option>' +
'</select>');
$item.append($select);
}
$preview.append($item);
}
/**
* 更新所有缩略图页码为 N/M 格式(流式渲染全部完成后调用)
* @param {number} totalPages
*/
function updateThumbnailPageNumbers(totalPages) {
var $items = $('#paginated-preview .preview-thumb-item');
$items.each(function (i) {
$(this).find('.preview-thumb-page-num').text((i + 1) + '/' + totalPages);
});
}
/**
* 将页面渲染到隐藏区域并高质量截图(用于保存)
* @returns {jQuery Deferred} resolves with canvas array
*/
function capturePagesFromStaging() {
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
return doCapturePages({
scale: 2,
outputCanvas: true,
quality: 0.92,
sizeConfig: sizeConfig
});
}
// ===== 图片生成 =====
/**
* 逐页截图生成图片(使用隐藏渲染区域高质量截图)
* @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas)
* @returns {Promise} jQuery Deferred, resolves with array of canvas objects
*/
function generateImages(onProgress) {
var deferred = $.Deferred();
if (pages.length === 0) {
deferred.reject('没有可渲染的页面');
return deferred.promise();
}
if (onProgress) onProgress(0, pages.length, null);
capturePagesFromStaging().then(function (canvases) {
if (onProgress) onProgress(canvases.length, canvases.length, null);
deferred.resolve(canvases);
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 将canvas转为base64并保存到服务端
* @param {number} postId 文章ID
* @param {Object} saveConfig 配置信息
* @param {Function} [onProgress] 生成进度回调 function(current, total, canvas)
* @returns {Promise} jQuery Deferred
*/
function saveImages(postId, saveConfig, onProgress) {
var deferred = $.Deferred();
// 第一步:生成分页图片和长图
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));
}
// 第二步:生成长图
return generateLongImageBase64().then(function(longBase64) {
return { pages: pagesData, longImage: longBase64 };
});
}).then(function(result) {
var pagesData = result.pages;
var longImageData = result.longImage;
// 检查数据总大小,防止超大文章导致请求失败
var totalSize = 0;
for (var i = 0; i < pagesData.length; i++) {
if (pagesData[i]) totalSize += pagesData[i].length;
}
if (longImageData) totalSize += longImageData.length;
if (totalSize > 16 * 1024 * 1024) {
layer.msg('数据量过大超过16MB请减少内容或联系管理员');
deferred.reject('数据量过大超过16MB');
return;
}
// 第三步:发送到后端
$.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,
long_image: longImageData
}),
contentType: 'application/json',
success: function (result) {
if (result.code === 0) {
deferred.resolve(result.data);
} else {
deferred.reject(result.msg || '保存失败');
}
},
error: function (xhr) {
// 容错后端可能返回200 JSON + 500错误页面的混合响应
// 尝试从响应文本开头提取有效JSON
try {
var text = xhr.responseText || '';
var jsonEnd = text.indexOf('}');
if (jsonEnd > 0) {
var jsonStr = text.substring(0, jsonEnd + 1);
var parsed = JSON.parse(jsonStr);
if (parsed && parsed.code === 0) {
deferred.resolve(parsed.data || {});
return;
}
}
} catch (e) { /* 解析失败,走正常错误流程 */ }
deferred.reject('网络错误: ' + xhr.statusText);
}
});
}).catch(function (err) {
deferred.reject(err);
});
return deferred.promise();
}
/**
* 保存排版配置到服务端(不生成图片)
* @param {number} postId 文章ID
* @param {Object} saveConfig 配置信息 {size, fontSize, watermark, content_html}
* @param {string} url 保存接口URL
* @returns {Promise} jQuery Deferred, resolves with {output_id}
*/
function saveConfig(postId, saveConfig, url) {
var deferred = $.Deferred();
var payload = {
post_id: postId,
output_type: 'phone_image',
config: {
size: saveConfig.size || config.size,
watermark: saveConfig.watermark || config.watermark,
fontScale: config.fontScale || 1,
tableFontScale: config.tableFontScale || 1,
pageAlignments: config.pageAlignments || {}
},
content_html: saveConfig.content_html || postData.content_html
};
$.ajax({
url: url || '',
type: 'POST',
data: JSON.stringify(payload),
contentType: 'application/json',
success: function (result) {
if (result.code === 0) {
deferred.resolve(result.data || {});
} else {
deferred.reject(result.msg || '保存失败');
}
},
error: function (xhr) {
// 容错后端可能返回200 JSON + 500错误页面的混合响应
try {
var text = xhr.responseText || '';
var jsonEnd = text.indexOf('}');
if (jsonEnd > 0) {
var jsonStr = text.substring(0, jsonEnd + 1);
var parsed = JSON.parse(jsonStr);
if (parsed && parsed.code === 0) {
deferred.resolve(parsed.data || {});
return;
}
}
} catch (e) { /* 解析失败,走正常错误流程 */ }
deferred.reject('网络错误: ' + xhr.statusText);
}
});
return deferred.promise();
}
// ===== 逐页对齐 =====
/**
* 设置指定页的对齐方式
* @param {number} pageNum 页码(1-based)
* @param {string} align 'top'|'center'
*/
function setPageAlignment(pageNum, align) {
if (!config.pageAlignments) {
config.pageAlignments = {};
}
config.pageAlignments[pageNum] = align;
}
// ===== 长图base64生成不触发下载 =====
/**
* 生成长图base64不触发下载
* @returns {jQuery Deferred} resolves with base64 string or null
*/
function generateLongImageBase64() {
var deferred = $.Deferred();
var editorChildren = getEditorChildren();
if (editorChildren.length === 0) {
deferred.resolve(null);
return deferred.promise();
}
var $container = $('<div>').css({
width: '540px',
position: 'fixed',
left: '-9999px',
top: '0',
visibility: 'visible',
background: '#ffffff',
padding: '20px',
boxSizing: 'border-box',
fontSize: '14px',
lineHeight: '1.8'
});
for (var i = 0; i < editorChildren.length; i++) {
var el = editorChildren[i];
var isDivider = el.getAttribute('data-w-e-type') === 'divider' ||
el.tagName.toLowerCase() === 'hr';
if (!isDivider) {
$container.append(el.cloneNode(true));
}
}
$('body').append($container);
requestAnimationFrame(function () {
html2canvas($container[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: $container.outerWidth(),
height: $container[0].scrollHeight,
logging: false
}).then(function (canvas) {
$container.remove();
// 使用JPEG压缩减少体积
deferred.resolve(canvas.toDataURL('image/jpeg', 0.85));
}).catch(function (err) {
$container.remove();
// 长图生成失败不阻断主流程
console.warn('长图生成失败:', err);
deferred.resolve(null);
});
});
return deferred.promise();
}
// ===== 导出长图 =====
/**
* 导出长图:从 wangeditor DOM 截取完整长图
* @returns {jQuery Deferred} resolves with canvas
*/
function exportLongImage() {
var deferred = $.Deferred();
if (render._locked) {
deferred.reject('rendering');
return deferred.promise();
}
var editorChildren = getEditorChildren();
if (editorChildren.length === 0) {
deferred.reject('编辑器内容为空');
return deferred.promise();
}
// 创建临时容器宽度540px与编辑器一致
var $container = $('<div>').css({
width: '540px',
position: 'fixed',
left: '-9999px',
top: '0',
visibility: 'visible',
background: '#ffffff',
padding: '20px',
boxSizing: 'border-box',
fontSize: '14px',
lineHeight: '1.8'
});
// 克隆所有非 divider 的子元素
for (var i = 0; i < editorChildren.length; i++) {
var el = editorChildren[i];
var isDivider = el.getAttribute('data-w-e-type') === 'divider' ||
el.tagName.toLowerCase() === 'hr';
if (!isDivider) {
$container.append(el.cloneNode(true));
}
}
$('body').append($container);
requestAnimationFrame(function () {
var width = $container.outerWidth();
var height = $container[0].scrollHeight;
html2canvas($container[0], {
scale: 2,
useCORS: true,
backgroundColor: '#ffffff',
width: width,
height: height,
logging: false
}).then(function (canvas) {
$container.remove();
var link = document.createElement('a');
link.download = 'phone-image-long-' + Date.now() + '.png';
link.href = canvas.toDataURL('image/png');
link.click();
deferred.resolve(canvas);
}).catch(function (err) {
$container.remove();
deferred.reject('长图导出失败: ' + err);
});
});
return deferred.promise();
}
// ===== 工具方法 =====
/**
* HTML转义
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
// ===== 对齐下拉事件委托 =====
$(document).on('change', '.thumb-alignment-select', function () {
var $select = $(this);
var pageNum = parseInt($select.attr('data-page-num'), 10);
var align = $select.val();
setPageAlignment(pageNum, align);
// 仅CSS变更不触发完整重渲染
var $thumbItem = $select.closest('.preview-thumb-item');
var $thumbImg = $thumbItem.find('.preview-thumb-img');
if (align === 'center') {
$thumbImg.css({ 'align-self': 'center' });
} else if (align === 'bottom') {
$thumbImg.css({ 'align-self': 'flex-end' });
} else {
$thumbImg.css({ 'align-self': 'flex-start' });
}
});
// ===== 公开API =====
return {
init: init,
render: render,
generateImages: generateImages,
saveImages: saveImages,
saveConfig: saveConfig,
syncPreview: syncPreview,
setPageAlignment: setPageAlignment,
exportLongImage: exportLongImage,
logPanel: PhoneImageLogPanel,
getContentHtml: function () {
if (window.phoneImageEditor) {
return window.phoneImageEditor.getHtml();
}
return postData.content_html;
},
updateConfig: function (newConfig) {
if (newConfig) {
$.extend(config, newConfig);
}
applyFontScale();
}
};
})();