paper-burner/js/history/history_pdf_compare.js

2807 lines
102 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.

/**
* history_pdf_compare.js
* MinerU 结构化翻译的 PDF 对照视图(左右对照 + 长画布 + 懒加载)
*/
class PDFCompareView {
constructor() {
this.pdfDoc = null;
this.currentPage = 1;
this.totalPages = 0;
this.scale = 1.0;
this.dpr = window.devicePixelRatio || 1;
// 左右两侧画布与覆盖层(长画布)
this.originalCanvas = null;
this.originalContext = null;
this.originalOverlay = null;
this.originalOverlayContext = null;
this.translationCanvas = null;
this.translationContext = null;
this.translationOverlay = null;
this.translationOverlayContext = null;
// 数据
this.contentListJson = null;
this.translatedContentList = null;
this.layoutJson = null;
// 连续模式 & 懒加载
this.mode = 'continuous';
this.pageInfos = [];
this._lazyScrollTimer = null;
this._lazyInitialized = false;
this._renderingVisible = false;
this._pendingVisibleRender = false;
// PDF.js 渲染串行队列(避免同一 canvas 并发 render 报错)
this._renderQueueOriginal = Promise.resolve();
this._renderQueueTranslation = Promise.resolve();
// PDF 文本层缓存(用于精确清除文字)
this.pageTextLayers = new Map(); // pageNum -> textContent
// ============ 新模块化架构 ============
// 初始化文本自适应渲染模块
this.textFittingAdapter = null;
// 兼容性:保留原有属性
this.textFittingEngine = null;
this.globalFontSizeCache = new Map();
this.hasPreprocessed = false;
// 初始化PDF导出模块
this.pdfExporter = null;
// 初始化分段管理模块
this.segmentManager = null;
// 初始化模块(延迟到 initialize 方法,确保依赖加载)
this._initializeModules();
}
/**
* 初始化各个功能模块
*/
_initializeModules() {
try {
// 初始化文本自适应模块
if (typeof TextFittingAdapter !== 'undefined') {
this.textFittingAdapter = new TextFittingAdapter({
initialScale: 1.0,
minScale: 0.3,
scaleStepHigh: 0.05,
scaleStepLow: 0.1,
lineSkipCJK: 1.5,
lineSkipWestern: 1.3,
minLineHeight: 1.05,
globalFontScale: 0.85,
bboxNormalizedRange: 1000
});
console.log('[PDFCompareView] TextFittingAdapter 已初始化');
} else {
console.warn('[PDFCompareView] TextFittingAdapter 未加载,将使用回退方案');
}
// 初始化PDF导出模块
if (typeof PDFExporter !== 'undefined') {
this.pdfExporter = new PDFExporter({
bboxNormalizedRange: 1000
});
console.log('[PDFCompareView] PDFExporter 已初始化');
} else {
console.warn('[PDFCompareView] PDFExporter 未加载,导出功能将不可用');
}
// SegmentManager 将在 renderAllPagesContinuous 中初始化,因为需要 pdfDoc
} catch (error) {
console.error('[PDFCompareView] 模块初始化失败:', error);
}
}
/**
* 初始化文本自适应引擎
*/
initializeTextFitting() {
// 优先使用新模块
if (this.textFittingAdapter) {
this.textFittingAdapter.initialize();
// 兼容性:同步到旧属性
this.textFittingEngine = this.textFittingAdapter.textFittingEngine;
return;
}
// 回退:使用原有实现
if (typeof TextFittingEngine === 'undefined') {
console.error('[PDFCompareView] TextFittingEngine 未加载!请确保 js/utils/text-fitting.js 已正确引入');
console.error('[PDFCompareView] 当前可用类:', typeof TextFittingEngine, typeof PDFTextRenderer);
return;
}
try {
this.textFittingEngine = new TextFittingEngine({
initialScale: 1.0,
minScale: 0.3,
scaleStepHigh: 0.05,
scaleStepLow: 0.1,
lineSkipCJK: 1.5,
lineSkipWestern: 1.3,
minLineHeight: 1.05
});
console.log('[PDFCompareView] 文本自适应引擎已启用');
} catch (error) {
console.error('[PDFCompareView] 文本自适应引擎初始化失败:', error);
}
}
/**
* 预处理:计算全局统一的字号
*/
preprocessGlobalFontSizes() {
// 优先使用新模块
if (this.textFittingAdapter) {
this.textFittingAdapter.preprocessGlobalFontSizes(
this.contentListJson,
this.translatedContentList
);
// 兼容性:同步缓存
this.globalFontSizeCache = this.textFittingAdapter.globalFontSizeCache;
this.hasPreprocessed = this.textFittingAdapter.hasPreprocessed;
return;
}
// 回退:使用原有实现
if (this.hasPreprocessed) return;
console.log('[PDFCompareView] 开始预处理全局字号...');
const startTime = performance.now();
const globalFontScale = 0.85;
this.contentListJson.forEach((item, idx) => {
if (item.type !== 'text' || !item.bbox) return;
const translatedItem = this.translatedContentList[idx];
if (!translatedItem || !translatedItem.text) return;
const bbox = item.bbox;
const BBOX_NORMALIZED_RANGE = 1000;
const height = (bbox[3] - bbox[1]) / BBOX_NORMALIZED_RANGE;
const estimatedFontSize = height * globalFontScale;
this.globalFontSizeCache.set(idx, {
estimatedFontSize: estimatedFontSize,
bbox: bbox
});
});
console.log(`[PDFCompareView] 预处理完成:全局缩放=${globalFontScale}, 耗时=${(performance.now() - startTime).toFixed(0)}ms`);
this.hasPreprocessed = true;
}
/**
* 获取并缓存页面的文本层信息
* @param {number} pageNum - 页码1-based
* @returns {Promise<Array>} 文本项数组
*/
async getPageTextLayer(pageNum) {
// 检查缓存
if (this.pageTextLayers.has(pageNum)) {
return this.pageTextLayers.get(pageNum);
}
try {
const page = await this.pdfDoc.getPage(pageNum);
const textContent = await page.getTextContent();
// 缓存结果
this.pageTextLayers.set(pageNum, textContent.items);
console.log(`[PDFCompareView] 页面 ${pageNum} 文本层已缓存,共 ${textContent.items.length} 个文本项`);
return textContent.items;
} catch (error) {
console.error(`[PDFCompareView] 获取页面 ${pageNum} 文本层失败:`, error);
return [];
}
}
/**
* 精确清除 bbox 内的原始文字(只清除文字,保留背景)
* @param {CanvasRenderingContext2D} ctx - Canvas 上下文
* @param {number} pageNum - 页码1-based
* @param {Object} bbox - 边界框 { x, y, w, h }canvas 坐标)
* @param {number} yOffset - Y 轴偏移(用于连续模式)
*/
async clearTextInBbox(ctx, pageNum, bbox, yOffset = 0) {
const textItems = await this.getPageTextLayer(pageNum);
if (!textItems || textItems.length === 0) {
console.warn(`[clearTextInBbox] 页面 ${pageNum} 没有文本层数据`);
return;
}
const page = await this.pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: this.scale * this.dpr });
console.log(`[clearTextInBbox] 页面 ${pageNum}, bbox: x=${bbox.x.toFixed(1)}, y=${bbox.y.toFixed(1)}, w=${bbox.w.toFixed(1)}, h=${bbox.h.toFixed(1)}, yOffset=${yOffset.toFixed(1)}, viewport: ${viewport.width}x${viewport.height}`);
let clearedCount = 0;
let checkedCount = 0;
// 遍历所有文本项,找出与 bbox 重叠的
for (const item of textItems) {
if (!item.transform) continue;
checkedCount++;
// PDF.js 文本项的坐标转换
// transform: [scaleX, skewY, skewX, scaleY, translateX, translateY]
const transform = item.transform;
const tx = transform[4] * this.scale * this.dpr;
const ty = transform[5] * this.scale * this.dpr;
const itemWidth = item.width * this.scale * this.dpr;
const itemHeight = item.height * this.scale * this.dpr;
// 转换为 Canvas 坐标(考虑 Y 轴翻转)
const textX = tx;
const textY = viewport.height - ty - itemHeight;
const textW = itemWidth;
const textH = itemHeight;
// 注意:这里的 bbox.y 已经包含了 yOffset在调用时计算好的
// 所以 textY 也需要加上 yOffset 才能对齐
const textRect = { x: textX, y: textY + yOffset, w: textW, h: textH };
// 调试:显示前几个文本项的坐标
if (clearedCount === 0 && checkedCount <= 3) {
console.log(` 文本项 ${checkedCount}: str="${item.str}", textRect: x=${textRect.x.toFixed(1)}, y=${textRect.y.toFixed(1)}, w=${textRect.w.toFixed(1)}, h=${textRect.h.toFixed(1)}`);
}
// 检查是否与 bbox 重叠
if (this.checkRectOverlap(textRect, bbox)) {
// 在文本区域填充白色(略微扩大边界以确保覆盖完整)
const padding = 2;
ctx.fillStyle = 'rgba(255, 255, 255, 1.0)'; // 完全不透明
ctx.fillRect(
textX - padding,
textY + yOffset - padding,
textW + padding * 2,
textH + padding * 2
);
clearedCount++;
// 调试:绘制红色边框显示清除区域(已关闭)
if (false) {
ctx.strokeStyle = 'rgba(255, 0, 0, 0.8)';
ctx.lineWidth = 2;
ctx.strokeRect(textX - padding, textY + yOffset - padding, textW + padding * 2, textH + padding * 2);
}
// 调试:显示清除的文本
if (clearedCount <= 5) {
console.log(` 清除文本 ${clearedCount}: "${item.str}" at (${textX.toFixed(1)}, ${(textY + yOffset).toFixed(1)})`);
}
}
}
console.log(`[clearTextInBbox] 页面 ${pageNum} 检查了 ${checkedCount} 个文本项,清除了 ${clearedCount}`);
}
/**
* 检查两个矩形是否重叠
* @param {Object} rect1 - { x, y, w, h }
* @param {Object} rect2 - { x, y, w, h }
* @returns {boolean}
*/
checkRectOverlap(rect1, rect2) {
return !(
rect1.x + rect1.w < rect2.x || // rect1 在 rect2 左边
rect1.x > rect2.x + rect2.w || // rect1 在 rect2 右边
rect1.y + rect1.h < rect2.y || // rect1 在 rect2 上边
rect1.y > rect2.y + rect2.h // rect1 在 rect2 下边
);
}
/**
* 初始化 PDF 对照视图
* @param {string} pdfBase64 - PDF 文件的 base64 字符串
* @param {Array} contentListJson - content_list.json 数据
* @param {Array} translatedContentList - 翻译后的内容列表
* @param {Object} layoutJson - layout.json 数据(包含页面真实尺寸)
*/
async initialize(pdfBase64, contentListJson, translatedContentList, layoutJson) {
// 初始化文本自适应引擎
this.initializeTextFitting();
// 保存原始PDF数据用于导出
this.originalPdfBase64 = pdfBase64;
this.contentListJson = contentListJson;
this.translatedContentList = translatedContentList;
this.layoutJson = layoutJson;
// 将 base64 转换为 Uint8Array
const pdfData = this.base64ToUint8Array(pdfBase64);
// 加载 PDF
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
this.pdfDoc = await loadingTask.promise;
this.totalPages = this.pdfDoc.numPages;
console.log(`[PDFCompareView] PDF 加载成功,共 ${this.totalPages}`);
// 从 layout.json 和 contentListJson 提取每页的图像尺寸
this.pageImageSizes = {};
// 方法1: 从 contentListJson 分析每页的 bbox 最大值(最可靠)
if (contentListJson && Array.isArray(contentListJson)) {
console.log('[PDFCompareView] 从 contentListJson 分析 bbox 范围...');
contentListJson.forEach(item => {
if (item.bbox && item.page_idx !== undefined) {
const pageIdx = item.page_idx;
if (!this.pageImageSizes[pageIdx]) {
this.pageImageSizes[pageIdx] = { width: 0, height: 0 };
}
// bbox: [x0, y0, x1, y1]
this.pageImageSizes[pageIdx].width = Math.max(this.pageImageSizes[pageIdx].width, item.bbox[2]);
this.pageImageSizes[pageIdx].height = Math.max(this.pageImageSizes[pageIdx].height, item.bbox[3]);
}
});
console.log('[PDFCompareView] 从 contentListJson 推断的页面尺寸:', this.pageImageSizes);
}
// 方法2: 从 layout.json 获取(作为补充)
if (layoutJson) {
console.log('[PDFCompareView] layout.json 类型:', typeof layoutJson);
// 检查是否是 { "pdf_info": [...] } 格式
let pdfInfoArray = null;
if (layoutJson.pdf_info && Array.isArray(layoutJson.pdf_info)) {
pdfInfoArray = layoutJson.pdf_info;
console.log('[PDFCompareView] 从 layoutJson.pdf_info 获取数据,共', pdfInfoArray.length, '页');
} else if (Array.isArray(layoutJson)) {
pdfInfoArray = layoutJson;
console.log('[PDFCompareView] layoutJson 直接是数组');
}
if (pdfInfoArray) {
pdfInfoArray.forEach((pageData) => {
const index = pageData.page_idx !== undefined ? pageData.page_idx : pdfInfoArray.indexOf(pageData);
// 从 preproc_blocks 获取 bbox 最大值
if (pageData.preproc_blocks && Array.isArray(pageData.preproc_blocks)) {
let maxX = 0, maxY = 0;
pageData.preproc_blocks.forEach(block => {
if (block.bbox) {
maxX = Math.max(maxX, block.bbox[2]);
maxY = Math.max(maxY, block.bbox[3]);
}
});
if (maxX > 0 && maxY > 0) {
// 如果从 preproc_blocks 得到的尺寸更大,使用它
if (!this.pageImageSizes[index] || maxX > this.pageImageSizes[index].width) {
this.pageImageSizes[index] = { width: maxX, height: maxY };
console.log(`[PDFCompareView] 页面 ${index} 从 preproc_blocks 获取尺寸:`, this.pageImageSizes[index]);
}
}
}
});
}
}
console.log('[PDFCompareView] 最终页面图像尺寸:', this.pageImageSizes);
}
/**
* Base64 转 Uint8Array
*/
base64ToUint8Array(base64) {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
/**
* 渲染 HTML 结构
*/
renderHTML() {
return `
<div class="pdf-compare-container" style="display: flex; height: 100vh; gap: 0; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #f5f5f5; z-index: 1000;">
<!-- 左侧:原文 PDF (50% 宽度) -->
<div class="pdf-viewer-area pdf-original" style="flex: 1; border-right: 1px solid #e0e0e0; overflow: auto; background: #fff; position: relative; display: flex; flex-direction: column;">
<div class="pdf-controls" style="height: 56px; background: #ffffff; padding: 0 24px; border-bottom: 1px solid #e0e0e0; z-index: 10; display: flex; gap: 12px; align-items: center; flex-shrink: 0;">
<div style="display: flex; gap: 8px; align-items: center;">
<button id="pdf-prev-page" style="padding: 7px 14px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; color: #495057; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;" onmouseover="this.style.background='#e9ecef'" onmouseout="this.style.background='#f8f9fa'">← 上一页</button>
<button id="pdf-next-page" style="padding: 7px 14px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; color: #495057; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;" onmouseover="this.style.background='#e9ecef'" onmouseout="this.style.background='#f8f9fa'">下一页 →</button>
</div>
<span id="pdf-page-info" style="color: #6c757d; font-size: 13px; font-weight: 500; padding: 0 8px;">第 1 页 / 共 0 页</span>
<div style="flex: 1;"></div>
<div style="display: flex; gap: 6px; align-items: center; background: #f8f9fa; padding: 4px; border-radius: 6px;">
<button id="pdf-zoom-out" style="width: 32px; height: 32px; background: transparent; border: none; color: #495057; cursor: pointer; font-size: 18px; font-weight: 600; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s;" onmouseover="this.style.background='#e9ecef'" onmouseout="this.style.background='transparent'"></button>
<span id="pdf-zoom-level" style="color: #495057; font-size: 13px; font-weight: 500; min-width: 48px; text-align: center;">100%</span>
<button id="pdf-zoom-in" style="width: 32px; height: 32px; background: transparent; border: none; color: #495057; cursor: pointer; font-size: 18px; font-weight: 600; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.2s;" onmouseover="this.style.background='#e9ecef'" onmouseout="this.style.background='transparent'">+</button>
</div>
<button id="pdf-exit-fullscreen" style="padding: 7px 16px; background: #dc3545; border: none; border-radius: 6px; color: white; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;" onmouseover="this.style.background='#c82333'" onmouseout="this.style.background='#dc3545'">退出对照</button>
</div>
<div class="pdf-scroll-area" id="pdf-original-scroll" style="flex: 1; overflow: auto; padding: 20px; position: relative; background: #f5f5f5;">
<div id="pdf-original-segments" class="pdf-segments" style="position: relative; display: block;"></div>
</div>
</div>
<!-- 右侧:译文 PDF (50% 宽度) -->
<div class="pdf-viewer-area pdf-translation" style="flex: 1; overflow: auto; background: #fff; position: relative; display: flex; flex-direction: column;">
<div class="pdf-controls" style="height: 56px; background: #ffffff; padding: 0 24px; border-bottom: 1px solid #e0e0e0; z-index: 10; display: flex; gap: 12px; align-items: center; flex-shrink: 0;">
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: #e8f5e9; border-radius: 20px;">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="flex-shrink: 0;"><path d="M3 5.5C3 4.67157 3.67157 4 4.5 4H11.5C12.3284 4 13 4.67157 13 5.5V12.5C13 13.3284 12.3284 14 11.5 14H4.5C3.67157 14 3 13.3284 3 12.5V5.5Z" stroke="#2e7d32" stroke-width="1.5"/><path d="M5 7H11M5 9.5H9" stroke="#2e7d32" stroke-width="1.5" stroke-linecap="round"/></svg>
<span style="color: #2e7d32; font-size: 13px; font-weight: 600;">译文对照</span>
</div>
<!-- 导出按钮(右对齐) -->
<div style="margin-left: auto; display: flex; gap: 8px; align-items: center;">
<button id="pdf-export-structured-translation" style="padding: 7px 16px; background: #1976d2; border: none; border-radius: 6px; color: #fff; cursor: pointer; font-size: 13px; font-weight: 500; display: flex; align-items: center; gap: 6px; transition: all 0.2s; box-shadow: 0 2px 4px rgba(25, 118, 210, 0.2);" onmouseover="this.style.background='#1565c0'; this.style.boxShadow='0 4px 8px rgba(25, 118, 210, 0.3)'" onmouseout="this.style.background='#1976d2'; this.style.boxShadow='0 2px 4px rgba(25, 118, 210, 0.2)'" title="导出保留原格式的译文PDF">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="flex-shrink: 0;"><path d="M12 15V3M12 15L7 10M12 15L17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 17L2 19C2 20.1046 2.89543 21 4 21L20 21C21.1046 21 22 20.1046 22 19V17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>导出译文PDF</span>
</button>
</div>
</div>
<div class="pdf-scroll-area" id="pdf-translation-scroll" style="flex: 1; overflow: auto; padding: 20px; position: relative; background: #f5f5f5;">
<div id="pdf-translation-segments" class="pdf-segments" style="position: relative; display: block;"></div>
</div>
</div>
</div>
`;
}
/**
* 渲染页面并绑定事件
*/
async render(containerId) {
const container = document.getElementById(containerId);
if (!container) {
console.error('[PDFCompareView] 容器不存在:', containerId);
return;
}
container.innerHTML = this.renderHTML();
// 隐藏所有浮动图标,只保留 chatbot
this.hideFloatingIcons();
// 段容器(左右)
this.originalScroll = document.getElementById('pdf-original-scroll');
this.translationScroll = document.getElementById('pdf-translation-scroll');
this.originalSegmentsContainer = document.getElementById('pdf-original-segments');
this.translationSegmentsContainer = document.getElementById('pdf-translation-segments');
// 段列表
this.segments = []; // 每段包含左右两侧 canvas/overlay 与段内页列表
// 渲染所有页面(连续滚动模式)
await this.renderAllPagesContinuous();
// 绑定事件
this.bindEvents();
// 绑定滚动联动
this.bindScrollSync();
// 绑定 bbox 点击事件
this.bindBboxClick();
}
/**
* 连续滚动模式:懒加载渲染所有页面到一个长画布
*/
async renderAllPagesContinuous() {
this.mode = 'continuous';
// 优先使用新模块
if (typeof SegmentManager !== 'undefined') {
// 初始化 SegmentManager
this.segmentManager = new SegmentManager(this.pdfDoc, {
maxSegmentPixels: this.dpr >= 2 ? 4096 : 8192,
bufferRatio: 0.5,
scrollDebounceMs: 80,
bboxNormalizedRange: 1000
});
// 设置容器
this.segmentManager.setContainers(
this.originalSegmentsContainer,
this.translationSegmentsContainer,
document.getElementById('pdf-original-scroll'),
document.getElementById('pdf-translation-scroll')
);
// 设置依赖注入
this.segmentManager.setDependencies({
renderPageBboxesToCtx: this.renderPageBboxesToCtx.bind(this),
renderPageTranslationToCtx: this.renderPageTranslationToCtx.bind(this),
clearTextInBbox: this.clearTextInBbox.bind(this),
clearFormulaElementsForPageInWrapper: this.clearFormulaElementsForPageInWrapper.bind(this),
onOverlayClick: this.onSegmentOverlayClick.bind(this),
contentListJson: this.contentListJson
});
// 执行渲染
await this.segmentManager.renderAllPagesContinuous();
// 同步属性(兼容性)
this.pageInfos = this.segmentManager.pageInfos;
this.scale = this.segmentManager.scale;
this.segments = this.segmentManager.segments;
// 更新UI
document.getElementById('pdf-page-info').textContent = `${this.totalPages}`;
document.getElementById('pdf-zoom-level').textContent = `${Math.round(this.scale * 100)}%`;
return;
}
// 回退:使用原有实现
// 计算自适应缩放
const firstPage = await this.pdfDoc.getPage(1);
const originalViewport = firstPage.getViewport({ scale: 1.0 });
const containerWidth = document.getElementById('pdf-original-scroll').clientWidth - 40;
this.scale = Math.min(containerWidth / originalViewport.width, 1.5);
const dpr = this.dpr;
console.log(`[PDF连续模式] DPR=${dpr}, scale=${this.scale}`);
// 计算所有页面尺寸(物理像素)并分段
this.pageInfos = [];
let totalHeight = 0;
for (let i = 1; i <= this.totalPages; i++) {
const page = await this.pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: this.scale * dpr });
const info = { pageNum: i, page, viewport, yOffset: totalHeight, width: viewport.width, height: viewport.height };
this.pageInfos.push(info);
totalHeight += viewport.height;
}
// 清空旧段容器
this.originalSegmentsContainer.innerHTML = '';
this.translationSegmentsContainer.innerHTML = '';
this.segments = [];
const MAX_SEG_PX = (dpr >= 2 ? 4096 : 8192);
const canvasWidth = this.pageInfos.length > 0 ? this.pageInfos[0].width : Math.round(originalViewport.width * this.scale * dpr);
let currentSeg = null;
let currentSegHeight = 0;
let currentSegTop = 0;
const startNewSegment = () => {
const segIndex = this.segments.length;
if (currentSeg) currentSegTop += currentSegHeight;
currentSegHeight = 0;
const seg = {
index: segIndex,
topPx: currentSegTop,
heightPx: 0,
widthPx: canvasWidth,
pages: [], // { pageNum, page, viewport, yInSegPx, width, height }
left: null,
right: null,
rendered: false,
rendering: false,
textCleared: false, // 标记是否已清除文字(避免重复清除)
};
this.segments.push(seg);
currentSeg = seg;
};
startNewSegment();
for (const p of this.pageInfos) {
if (currentSegHeight > 0 && (currentSegHeight + p.height) > MAX_SEG_PX) {
currentSeg.heightPx = currentSegHeight;
startNewSegment();
}
const yInSeg = currentSegHeight;
currentSeg.pages.push({ pageNum: p.pageNum, page: p.page, viewport: p.viewport, yInSegPx: yInSeg, width: p.width, height: p.height });
currentSegHeight += p.height;
}
if (currentSeg) currentSeg.heightPx = currentSegHeight;
// 创建段 DOM
for (const seg of this.segments) {
this.createSegmentDom(seg, dpr);
}
// 初始化懒加载(按段)
this.initLazyLoadingSegments();
document.getElementById('pdf-page-info').textContent = `${this.totalPages}`;
document.getElementById('pdf-zoom-level').textContent = `${Math.round(this.scale * 100)}%`;
}
/**
* 创建一个段的左右 DOM 和上下文
*/
createSegmentDom(seg, dpr) {
const cssWidth = seg.widthPx / dpr;
const cssHeight = seg.heightPx / dpr;
const buildSide = (container, side) => {
const wrapper = document.createElement('div');
wrapper.className = 'pdf-segment-wrapper';
wrapper.style.position = 'relative';
wrapper.style.display = 'block';
wrapper.style.width = cssWidth + 'px';
wrapper.style.height = cssHeight + 'px';
wrapper.style.margin = '0';
const canvas = document.createElement('canvas');
canvas.width = seg.widthPx;
canvas.height = seg.heightPx;
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
const ctx = canvas.getContext('2d', { willReadFrequently: true, alpha: false });
const overlay = document.createElement('canvas');
overlay.width = seg.widthPx;
overlay.height = seg.heightPx;
overlay.style.width = cssWidth + 'px';
overlay.style.height = cssHeight + 'px';
overlay.style.position = 'absolute';
overlay.style.left = '0';
overlay.style.top = '0';
const overlayCtx = overlay.getContext('2d', { willReadFrequently: true });
wrapper.appendChild(canvas);
wrapper.appendChild(overlay);
container.appendChild(wrapper);
const sideObj = { wrapper, canvas, ctx, overlay, overlayCtx };
if (side === 'left') seg.left = sideObj; else seg.right = sideObj;
if (side === 'left') {
overlay.addEventListener('click', (e) => this.onSegmentOverlayClick(e, seg));
}
};
buildSide(this.originalSegmentsContainer, 'left');
buildSide(this.translationSegmentsContainer, 'right');
}
/**
* 初始化懒加载:监听滚动,渲染可见区域
*/
initLazyLoadingSegments() {
this.originalScroll = document.getElementById('pdf-original-scroll');
this.translationScroll = document.getElementById('pdf-translation-scroll');
if (!this.originalScroll || !this.translationScroll) return;
// 初始渲染可见段
this.renderVisibleSegments(this.originalScroll);
const onScroll = (scroller) => {
clearTimeout(this._lazyScrollTimer);
this._lazyScrollTimer = setTimeout(() => {
this.renderVisibleSegments(scroller);
}, 80);
};
if (!this._lazyInitialized) {
this.originalScroll.addEventListener('scroll', () => onScroll(this.originalScroll));
this.translationScroll.addEventListener('scroll', () => onScroll(this.translationScroll));
this._lazyInitialized = true;
}
}
/**
* 渲染当前可见的页面(视口+缓冲区)
*/
async renderVisibleSegments(container) {
if (!this.segments || this.segments.length === 0 || !container) return;
if (this._renderingVisible) { this._pendingVisibleRender = true; return; }
this._renderingVisible = true;
const dpr = this.dpr;
const scrollTopCss = container.scrollTop;
const viewportHeightCss = container.clientHeight;
const bufferCss = viewportHeightCss * 0.5;
const visibleStartPx = Math.max(0, (scrollTopCss - bufferCss) * dpr);
const visibleEndPx = (scrollTopCss + viewportHeightCss + bufferCss) * dpr;
for (const seg of this.segments) {
const segStart = seg.topPx;
const segEnd = seg.topPx + seg.heightPx;
const isVisible = segEnd >= visibleStartPx && segStart <= visibleEndPx;
// 关键:只要进入可见区且不在渲染中,就再次渲染该段
if (isVisible && !seg.rendering) {
try {
seg.rendering = true;
await this.renderSegment(seg);
seg.rendered = true;
} finally {
seg.rendering = false;
}
}
}
this._renderingVisible = false;
if (this._pendingVisibleRender) {
this._pendingVisibleRender = false;
this.renderVisibleSegments(container);
}
}
/**
* 在连续模式下渲染单个页面
*/
async renderSegment(seg) {
// 逐页渲染:先渲染到离屏小画布,再绘制到段画布,避免 pdf.js 在目标大画布上可能的清除行为
const off = document.createElement('canvas');
const offCtx = off.getContext('2d', { willReadFrequently: true, alpha: false });
for (const p of seg.pages) {
if (off.width !== p.width) off.width = p.width;
if (off.height !== p.height) off.height = p.height;
// 清理离屏
offCtx.clearRect(0, 0, off.width, off.height);
await p.page.render({ canvasContext: offCtx, viewport: p.viewport }).promise;
// 绘制到左右段 base 画布
seg.left.ctx.drawImage(off, 0, p.yInSegPx);
seg.right.ctx.drawImage(off, 0, p.yInSegPx);
}
// 绘制 overlays仅本段
// 注意:白色背景和翻译文本都在 overlay 层绘制,不会被 PDF 覆盖
await this.renderSegmentOverlays(seg);
}
/**
* 清除段内所有 bbox 的原始文字(在 base canvas 上操作)
*/
async clearTextInSegment(seg) {
const pageItems = this.contentListJson.filter(item => item.type === 'text');
const BBOX_NORMALIZED_RANGE = 1000;
for (const p of seg.pages) {
const pageNum = p.pageNum;
const scaleX = p.width / BBOX_NORMALIZED_RANGE;
const scaleY = p.height / BBOX_NORMALIZED_RANGE;
// 获取当前页的所有 bbox
const currentPageItems = pageItems.filter(item => item.page_idx === pageNum - 1);
for (const item of currentPageItems) {
if (!item.bbox) continue;
const bb = item.bbox;
const x = bb[0] * scaleX;
const y = bb[1] * scaleY + p.yInSegPx;
const w = (bb[2] - bb[0]) * scaleX;
const h = (bb[3] - bb[1]) * scaleY;
// 在右侧 base canvas 上精确清除文字
await this.clearTextInBbox(seg.right.ctx, pageNum, { x, y, w, h }, p.yInSegPx);
}
}
}
async renderSegmentOverlays(seg) {
const leftCtx = seg.left.overlayCtx;
const rightCtx = seg.right.overlayCtx;
// 清理段 overlay
leftCtx.clearRect(0, 0, seg.widthPx, seg.heightPx);
rightCtx.clearRect(0, 0, seg.widthPx, seg.heightPx);
// 清理该段所有页的公式 DOM
for (const p of seg.pages) {
this.clearFormulaElementsForPageInWrapper(p.pageNum, seg.right.wrapper);
}
// 绘制 overlays串行执行以确保文本层正确清除
for (const p of seg.pages) {
this.renderPageBboxesToCtx(leftCtx, p.pageNum, p.yInSegPx, p.width, p.height);
await this.renderPageTranslationToCtx(rightCtx, seg.right.wrapper, p.pageNum, p.yInSegPx, p.width, p.height);
}
}
/**
* 将渲染任务加入到指定画布的串行队列,防止 PDF.js 并发渲染报错
*/
enqueueCanvasRender(target, taskFactory) {
const key = target === 'original' ? '_renderQueueOriginal' : '_renderQueueTranslation';
const chain = this[key].then(() => taskFactory()).catch(err => {
console.warn('[PDFCompareView] render task failed:', err);
});
// 确保后续任务接在本次后面
this[key] = chain.then(() => undefined);
return chain;
}
/**
* 渲染单页的bbox
*/
renderPageBboxes(pageNum, yOffset, pageWidth, pageHeight) {
const pageItems = this.contentListJson.filter(item => item.page_idx === pageNum - 1 && item.type === 'text');
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = pageWidth / BBOX_NORMALIZED_RANGE;
const scaleY = pageHeight / BBOX_NORMALIZED_RANGE;
const ctx = this.originalOverlayContext;
pageItems.forEach(item => {
if (!item.bbox) return;
const bbox = item.bbox;
const x = bbox[0] * scaleX;
const y = bbox[1] * scaleY + yOffset;
const w = (bbox[2] - bbox[0]) * scaleX;
const h = (bbox[3] - bbox[1]) * scaleY;
ctx.strokeStyle = 'rgb(153, 0, 76)';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.7;
ctx.strokeRect(x, y, w, h);
ctx.globalAlpha = 1.0;
});
}
/**
* 渲染单页的翻译
*/
renderPageTranslation(pageNum, yOffset, pageWidth, pageHeight) {
const pageItems = this.contentListJson.filter(item => item.page_idx === pageNum - 1 && item.type === 'text');
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = pageWidth / BBOX_NORMALIZED_RANGE;
const scaleY = pageHeight / BBOX_NORMALIZED_RANGE;
const ctx = this.translationOverlayContext;
// 清理该页区域上的历史绘制与公式 DOM
ctx.clearRect(0, yOffset, pageWidth, pageHeight);
this.clearFormulaElementsForPage(pageNum);
pageItems.forEach((item, idx) => {
const originalIdx = this.contentListJson.indexOf(item);
const translatedItem = this.translatedContentList[originalIdx];
if (!translatedItem || !item.bbox) return;
const bbox = item.bbox;
const x = bbox[0] * scaleX;
const y = bbox[1] * scaleY + yOffset;
const w = (bbox[2] - bbox[0]) * scaleX;
const h = (bbox[3] - bbox[1]) * scaleY;
// 直接使用原始 bbox文本自适应引擎会自动处理
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.fillRect(x, y, w, h);
this.drawTextInBox(ctx, translatedItem.text, x, y, w, h, pageNum);
});
}
// 分段:将 bbox 绘制到指定 ctx
renderPageBboxesToCtx(ctx, pageNum, yOffset, pageWidth, pageHeight) {
const pageItems = this.contentListJson.filter(item => item.page_idx === pageNum - 1 && item.type === 'text');
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = pageWidth / BBOX_NORMALIZED_RANGE;
const scaleY = pageHeight / BBOX_NORMALIZED_RANGE;
pageItems.forEach(item => {
if (!item.bbox) return;
const b = item.bbox;
const x = b[0] * scaleX;
const y = b[1] * scaleY + yOffset;
const w = (b[2] - b[0]) * scaleX;
const h = (b[3] - b[1]) * scaleY;
ctx.strokeStyle = 'rgb(153, 0, 76)';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.7;
ctx.strokeRect(x, y, w, h);
ctx.globalAlpha = 1.0;
});
}
// 分段:将译文绘制到指定 ctx并将公式 DOM 插入到 wrapper
async renderPageTranslationToCtx(ctx, wrapperEl, pageNum, yOffset, pageWidth, pageHeight) {
// 确保已经完成预处理
if (!this.hasPreprocessed) {
this.preprocessGlobalFontSizes();
}
const pageItems = this.contentListJson.filter(item => item.page_idx === pageNum - 1 && item.type === 'text');
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = pageWidth / BBOX_NORMALIZED_RANGE;
const scaleY = pageHeight / BBOX_NORMALIZED_RANGE;
// 第一步:在 overlay 层绘制白色背景(覆盖原始文字)
pageItems.forEach((item) => {
if (!item.bbox) return;
const bb = item.bbox;
const x = bb[0] * scaleX;
const y = bb[1] * scaleY + yOffset;
const w = (bb[2] - bb[0]) * scaleX;
const h = (bb[3] - bb[1]) * scaleY;
// 向上下扩展白色背景(覆盖更多原始文字)
const verticalExpansion = h * 0.15; // 向上下各扩展 15%(配合溢出策略)
const expandedY = y - verticalExpansion;
const expandedH = h + verticalExpansion * 2;
// 在 overlay 层绘制扩展后的白色背景(不透明,遮挡下层的原始文字)
ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
ctx.fillRect(x, expandedY, w, expandedH);
});
// 第二步:绘制翻译文本(在白色背景上)
pageItems.forEach((item) => {
const originalIdx = this.contentListJson.indexOf(item);
const translatedItem = this.translatedContentList[originalIdx];
if (!translatedItem || !item.bbox) return;
const bb = item.bbox;
const x = bb[0] * scaleX;
const y = bb[1] * scaleY + yOffset;
const w = (bb[2] - bb[0]) * scaleX;
const h = (bb[3] - bb[1]) * scaleY;
// 使用预处理的字号信息
const cachedInfo = this.globalFontSizeCache.get(originalIdx);
// 使用新的文本自适应引擎渲染
this.drawTextInBox(ctx, translatedItem.text, x, y, w, h, pageNum, wrapperEl, cachedInfo);
});
}
/**
* 渲染指定页面双PDF
*/
async renderPage(pageNum) {
if (!this.pdfDoc) return;
this.currentPage = pageNum;
const page = await this.pdfDoc.getPage(pageNum);
// 计算自适应缩放比例
const originalViewport = page.getViewport({ scale: 1.0 });
const containerWidth = document.getElementById('pdf-original-scroll').clientWidth - 40; // 减去 padding
const autoScale = containerWidth / originalViewport.width;
// 如果是第一次渲染,使用自适应缩放
if (this.scale === 1.0 && pageNum === 1) {
this.scale = Math.min(autoScale, 1.5); // 最大不超过 150%
}
console.log(`[PDFCompareView] 自适应缩放计算: containerWidth=${containerWidth}, pdfWidth=${originalViewport.width}, autoScale=${autoScale}, 最终scale=${this.scale}`);
// 考虑设备像素比,提高清晰度
const dpr = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: this.scale * dpr });
// 保存页面的原始尺寸(未缩放)用于 bbox 坐标转换
this.currentPageOriginalWidth = originalViewport.width;
this.currentPageOriginalHeight = originalViewport.height;
// 设置原文 canvas 尺寸(物理像素)
this.originalCanvas.width = viewport.width;
this.originalCanvas.height = viewport.height;
this.originalOverlay.width = viewport.width;
this.originalOverlay.height = viewport.height;
// 设置 CSS 尺寸(逻辑像素)
this.originalCanvas.style.width = viewport.width / dpr + 'px';
this.originalCanvas.style.height = viewport.height / dpr + 'px';
this.originalOverlay.style.width = viewport.width / dpr + 'px';
this.originalOverlay.style.height = viewport.height / dpr + 'px';
// 设置译文 canvas 尺寸(物理像素)
this.translationCanvas.width = viewport.width;
this.translationCanvas.height = viewport.height;
this.translationOverlay.width = viewport.width;
this.translationOverlay.height = viewport.height;
// 设置 CSS 尺寸(逻辑像素)
this.translationCanvas.style.width = viewport.width / dpr + 'px';
this.translationCanvas.style.height = viewport.height / dpr + 'px';
this.translationOverlay.style.width = viewport.width / dpr + 'px';
this.translationOverlay.style.height = viewport.height / dpr + 'px';
console.log(`[PDFCompareView] Canvas 尺寸设置: 物理=${viewport.width}x${viewport.height}, CSS=${viewport.width/dpr}x${viewport.height/dpr}, DPR=${dpr}`);
// 渲染原文 PDF 页面
const renderContext = {
canvasContext: this.originalContext,
viewport: viewport
};
await page.render(renderContext).promise;
// 渲染译文 PDF 页面(先复制原文)
const translationRenderContext = {
canvasContext: this.translationContext,
viewport: viewport
};
await page.render(translationRenderContext).promise;
// 更新页码显示
document.getElementById('pdf-page-info').textContent = `${pageNum} 页 / 共 ${this.totalPages}`;
console.log(`[PDFCompareView] 页面 ${pageNum} 渲染完成,原始尺寸: ${this.currentPageOriginalWidth} x ${this.currentPageOriginalHeight}, 缩放后: ${viewport.width} x ${viewport.height}`);
// 在译文 PDF 上渲染翻译文本
this.renderTranslationOverlay(pageNum);
// 绘制所有 bbox可见性激活
this.renderAllBboxes(pageNum);
}
/**
* 渲染翻译内容列表(已废弃,改为双 PDF 模式)
*/
renderTranslationList() {
// 双 PDF 模式下不再需要翻译列表
}
/**
* 选中翻译项,高亮对应 PDF 区域(已废弃)
*/
selectTranslationItem(index) {
// 双 PDF 模式下不再需要
}
/**
* 在 PDF 上高亮 bbox 区域(保留用于兼容)
*/
highlightBboxOnPDF(index) {
// 双 PDF 模式下已废弃此功能
}
/**
* 渲染译文 PDF 上的翻译文本覆盖层
*/
renderTranslationOverlay(pageNum) {
const currentPageIndex = pageNum - 1;
const ctx = this.translationOverlayContext;
console.log('[renderTranslationOverlay] 开始渲染翻译层,页码:', pageNum);
// 清空overlay
ctx.clearRect(0, 0, this.translationOverlay.width, this.translationOverlay.height);
// 仅清理该页的公式 DOM 元素
this.clearFormulaElementsForPage(pageNum);
// 获取当前页的所有内容块(只处理文本类型)
const pageItems = this.contentListJson.filter(item =>
item.page_idx === currentPageIndex && item.type === 'text'
);
console.log('[renderTranslationOverlay] 当前页文本块数量:', pageItems.length);
const BBOX_NORMALIZED_RANGE = 1000;
// 使用物理像素尺寸进行计算
const scaleX = this.translationOverlay.width / BBOX_NORMALIZED_RANGE;
const scaleY = this.translationOverlay.height / BBOX_NORMALIZED_RANGE;
// 转换所有 bbox 到 canvas 坐标,用于碰撞检测
const canvasBboxes = pageItems.map(item => {
const bbox = item.bbox;
return {
x: bbox[0] * scaleX,
y: bbox[1] * scaleY,
w: (bbox[2] - bbox[0]) * scaleX,
h: (bbox[3] - bbox[1]) * scaleY,
originalItem: item
};
});
// 记录已绘制的文本块(用于自适应碰撞检测)
const drawnTextBoxes = [];
// 绘制每个文本 bbox 的背景色和翻译文字
pageItems.forEach((item, idx) => {
const originalIdx = this.contentListJson.indexOf(item);
const translatedItem = this.translatedContentList[originalIdx];
if (!translatedItem || !item.bbox) return;
const bbox = item.bbox;
const x = bbox[0] * scaleX;
const y = bbox[1] * scaleY;
const w = (bbox[2] - bbox[0]) * scaleX;
const h = (bbox[3] - bbox[1]) * scaleY;
// 智能扩展 bbox针对小标题和短段落特殊处理
const text = translatedItem.text || '';
const isShortText = text.length < 30; // 短文本可能是标题增加到30字符
const isVerySmallBox = w < 200 || h < 40; // 较小的 bbox增加阈值
const expandedBox = this.expandBboxIfPossible(
{ x, y, w, h },
canvasBboxes,
idx,
this.translationOverlay.width,
this.translationOverlay.height,
isShortText || isVerySmallBox // 传递标记,允许更大扩展
);
// 绘制白色/米色背景(覆盖原文)
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.fillRect(expandedBox.x, expandedBox.y, expandedBox.w, expandedBox.h);
// 自适应绘制文字(检测并避免与已绘制文本块重叠)
const actualBox = this.drawTextInBoxAdaptive(
ctx,
text,
expandedBox.x,
expandedBox.y,
expandedBox.w,
expandedBox.h,
pageNum,
drawnTextBoxes, // 传入已绘制的文本块
isShortText
);
// 记录已绘制的区域
if (actualBox) {
drawnTextBoxes.push(actualBox);
}
});
console.log('[renderTranslationOverlay] 翻译层渲染完成');
}
/**
* 智能扩展 bbox 区域(不与其他文本冲突)
* 针对小标题和短段落特殊处理,允许更大扩展
*/
expandBboxIfPossible(currentBox, allBboxes, currentIndex, canvasWidth, canvasHeight, allowLargeExpansion = false) {
const { x, y, w, h } = currentBox;
// 检测四个方向的可用空间
const availableSpace = {
top: y,
bottom: canvasHeight - (y + h),
left: x,
right: canvasWidth - (x + w)
};
const expansions = [];
if (allowLargeExpansion) {
// 小标题或短段落:允许大幅扩展,尽量一行显示
// 优先向右扩展(横向扩展)
const maxRightExpand = Math.min(availableSpace.right, canvasWidth * 0.5 - w); // 增加到50%
for (let dw of [50, 100, 150, 200, 250, 300, 350, 400]) { // 增加更多扩展选项
if (dw <= maxRightExpand) {
expansions.push({ x, y, w: w + dw, h });
// 也可以同时向下扩展一点
expansions.push({ x, y, w: w + dw, h: h + 20 }); // 增加到20px
expansions.push({ x, y, w: w + dw, h: h + 30 }); // 增加到30px
}
}
// 也尝试向下扩展
for (let dh of [20, 30, 40, 50, 60]) { // 增加更多选项
if (dh <= availableSpace.bottom) {
expansions.push({ x, y, w, h: h + dh });
}
}
// 尝试双向扩展
const dw = Math.min(150, maxRightExpand); // 增加到150
const dh = Math.min(30, availableSpace.bottom); // 增加到30
if (dw > 0 && dh > 0) {
expansions.push({ x, y, w: w + dw, h: h + dh });
}
} else {
// 常规段落(多行):严格保持在 bbox 内,不扩展
// 只使用原始 bbox
expansions.push({ x, y, w, h });
}
// 始终包含原始 bbox 作为后备选项
if (expansions.length === 0) {
expansions.push({ x, y, w, h });
}
// 找到最大的不冲突扩展
let bestExpansion = { x, y, w, h };
let maxArea = w * h;
for (let expanded of expansions) {
// 检查是否与其他 bbox 冲突(增加边距检查,防止贴得太近)
let hasCollision = false;
const margin = allowLargeExpansion ? 5 : 2; // 小标题需要更大边距
for (let i = 0; i < allBboxes.length; i++) {
if (i === currentIndex) continue;
const other = allBboxes[i];
// 添加边距检查
const expandedWithMargin = {
x: expanded.x - margin,
y: expanded.y - margin,
w: expanded.w + margin * 2,
h: expanded.h + margin * 2
};
if (this.checkBboxCollision(expandedWithMargin, other)) {
hasCollision = true;
break;
}
}
const area = expanded.w * expanded.h;
if (!hasCollision && area > maxArea) {
bestExpansion = expanded;
maxArea = area;
}
}
return bestExpansion;
}
/**
* 检查两个矩形是否碰撞
*/
checkBboxCollision(box1, box2) {
return !(
box1.x + box1.w < box2.x || // box1 在 box2 左边
box1.x > box2.x + box2.w || // box1 在 box2 右边
box1.y + box1.h < box2.y || // box1 在 box2 上边
box1.y > box2.y + box2.h // box1 在 box2 下边
);
}
/**
* 自适应绘制文字(检测并避免与已绘制文本块重叠)
* @returns {Object|null} 返回实际绘制的区域 { x, y, w, h }
*/
drawTextInBoxAdaptive(ctx, text, x, y, width, height, pageNum, drawnTextBoxes, isShortText) {
if (!text) return null;
// 检查是否包含公式
const hasBlockFormula = /\$\$[\s\S]+?\$\$/.test(text);
const hasInlineFormula = /\$[^$]*[\\^_{}a-zA-Z][\s\S]*?\$/.test(text);
const hasFormula = hasBlockFormula || hasInlineFormula;
// 暂时禁用公式渲染(用于测试)
if (false && hasFormula) {
// 根据 bbox 高度和已绘制文本自适应选择字号
const heightCss = height / this.dpr;
let bestFormulaFontSize;
// 字号候选列表(从大到小)
const fontSizeCandidates = isShortText
? [18, 16, 14, 12, 10, 8, 6]
: [13, 11, 10, 9, 8, 7, 6, 5, 4];
for (let fs of fontSizeCandidates) {
const lineHeight = 1.5;
const estimatedHeight = fs * lineHeight * 2.2; // 公式可能占2-3行高度
if (estimatedHeight <= heightCss) {
// 检查是否与已绘制文本重叠
const testBox = { x, y, w: width, h: estimatedHeight * this.dpr };
let hasCollision = false;
for (const drawn of drawnTextBoxes) {
if (this.checkBboxCollision(testBox, drawn)) {
hasCollision = true;
break;
}
}
if (!hasCollision) {
bestFormulaFontSize = fs;
break;
}
}
}
// 如果都不行,使用最小字号
if (!bestFormulaFontSize) {
bestFormulaFontSize = isShortText ? 6 : 4;
}
this.drawTextWithFormulaInBoxAdaptive(text, x, y, width, height, pageNum, null, isShortText, bestFormulaFontSize);
// 返回保守的高度估计(公式可能占用更多空间)
const actualHeight = Math.min(height, bestFormulaFontSize * 1.5 * 2.2 * this.dpr);
return { x, y, w: width, h: actualHeight };
}
// 纯文本:尝试不同字号,找到最大的不重叠字号
const maxFontSize = isShortText ? Math.max(width / 10, height / 3, 18) : Math.min(width / 10, height / 3);
const minFontSize = 3; // 允许缩到 3px除了单行小标题
let bestFontSize = minFontSize;
let bestLines = [];
let bestActualHeight = 0;
// 从大到小尝试字号
for (let fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 0.5) {
ctx.font = `${fontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
const lines = this.wrapText(ctx, text, width - 4);
const lineHeight = fontSize * 1.5; // 行间距 1.5
// 正确计算总高度:前 n-1 行用 lineHeight最后一行用 fontSize + 下方空间
const actualHeight = lines.length > 1
? (lines.length - 1) * lineHeight + fontSize * 1.3 + 8 // 最后一行留足空间 + 上下边距
: fontSize * 1.3 + 8; // 单行也留足空间
// 检查1是否在 bbox 内
if (actualHeight > height) {
continue; // 超出 bbox尝试更小字号
}
// 检查2是否与已绘制的文本块重叠
const textBox = { x, y, w: width, h: actualHeight };
let hasCollision = false;
for (const drawn of drawnTextBoxes) {
if (this.checkBboxCollision(textBox, drawn)) {
hasCollision = true;
break;
}
}
if (!hasCollision) {
// 找到了不重叠的字号
bestFontSize = fontSize;
bestLines = lines;
bestActualHeight = actualHeight;
break;
}
}
// 如果所有字号都重叠,继续降低字号直到 3px强制绘制
if (bestLines.length === 0) {
bestFontSize = 3;
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
bestLines = this.wrapText(ctx, text, width - 4);
const lineHeight = bestFontSize * 1.5;
// 正确计算总高度
const totalHeight = bestLines.length > 1
? (bestLines.length - 1) * lineHeight + bestFontSize * 1.3 + 8
: bestFontSize * 1.3 + 8;
// 如果还是放不下,裁剪行数
if (totalHeight > height) {
const availableHeight = height - 8;
const maxLines = Math.max(1, Math.floor(availableHeight / lineHeight));
bestLines = bestLines.slice(0, maxLines);
bestActualHeight = bestLines.length * lineHeight + 8;
} else {
bestActualHeight = totalHeight;
}
}
// 单行小标题最小字号限制(仅限单行)
if (isShortText && bestLines.length === 1 && bestFontSize < 14) {
bestFontSize = 14;
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
bestLines = this.wrapText(ctx, text, width - 4);
bestActualHeight = bestFontSize * 1.3 + 8;
}
// 绘制文字
ctx.fillStyle = '#000';
ctx.textBaseline = 'top';
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
const lineHeight = bestFontSize * 1.5; // 行间距 1.5
bestLines.forEach((line, i) => {
const lineY = y + 4 + i * lineHeight; // 上边距 4px
ctx.fillText(line, x + 2, lineY);
});
// 返回实际绘制的区域
return { x, y, w: width, h: bestActualHeight };
}
/**
* 在指定区域内绘制自适应大小的文字
*/
drawTextInBox(ctx, text, x, y, width, height, pageNum = null, wrapperEl = null, cachedInfo = null) {
if (!text) return;
// 检查是否为短文本/小标题(与 bbox 扩展判断保持一致)
const isShortText = text.length < 30;
// 检查是否包含公式(更严格的检测)
// 1. $$ 包围的块公式
// 2. $ 包围且包含数学符号的行内公式(不是简单的 $数字$
const hasBlockFormula = /\$\$[\s\S]+?\$\$/.test(text);
const hasInlineFormula = /\$[^$]*[\\^_{}a-zA-Z][\s\S]*?\$/.test(text); // 必须包含 \、^、_、{、}、字母等数学符号
const hasFormula = hasBlockFormula || hasInlineFormula;
if (hasFormula && wrapperEl) {
// 包含公式,使用 HTML 渲染(通过模块或回退方案)
this.drawTextWithFormulaInBox(text, x, y, width, height, pageNum, wrapperEl, isShortText, cachedInfo);
} else {
// 纯文本,使用 Canvas 渲染
this.drawPlainTextInBox(ctx, text, x, y, width, height, isShortText, cachedInfo);
}
}
/**
* 绘制纯文本Canvas
* @param {boolean} isShortText - 是否为短文本/小标题(会使用更大的最小字号)
* @param {Object} cachedInfo - 预处理的字号信息(可选)
*/
drawPlainTextInBox(ctx, text, x, y, width, height, isShortText = false, cachedInfo = null) {
// 直接使用新的文本自适应引擎
if (this.textFittingEngine) {
const suggestedFontSize = cachedInfo ? cachedInfo.estimatedFontSize : null;
return this.drawPlainTextWithFitting(ctx, text, x, y, width, height, isShortText, suggestedFontSize);
}
// 回退方案:如果引擎未初始化(不应该发生)
let bestFontSize = 8;
let bestLines = [];
// 从较大字号开始尝试,允许多行显示
// 多行文本使用更保守的最大字号
let maxFontSize = Math.min(width / 10, height / 3); // 调小,从 width/8, height/2.5 改为 width/10, height/3
// 小标题优先使用更大的字号
if (isShortText) {
maxFontSize = Math.max(maxFontSize, 18); // 小标题至少尝试 18px提高可读性
}
// 先尝试找到能完整放下所有文本的最大字号(从大到小)
let foundPerfectFit = false;
for (let fontSize = maxFontSize; fontSize >= 3; fontSize -= 0.5) { // 降到 3px
ctx.font = `${fontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
const lines = this.wrapText(ctx, text, width - 4);
const lineHeight = fontSize * 1.5; // 行间距 1.5
// 正确计算总高度:前 n-1 行用 lineHeight最后一行用 fontSize + 下方空间
const totalHeight = lines.length > 1
? (lines.length - 1) * lineHeight + fontSize * 1.3 + 8 // 最后一行留足空间 + 边距
: fontSize * 1.3 + 8; // 单行也留足空间
// 确保所有行都能完整显示(含行高)+ 额外留出缓冲空间
if (totalHeight <= height) { // 直接比较,不再减去缓冲(已包含在计算中)
bestFontSize = fontSize;
bestLines = lines;
foundPerfectFit = true;
break;
}
}
// 如果仍然找不到极端情况使用3px并裁剪行数
if (!foundPerfectFit) {
bestFontSize = 3;
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
const allLines = this.wrapText(ctx, text, width - 4);
const lineHeight = bestFontSize * 1.5;
// 正确计算总高度
const totalHeight = allLines.length > 1
? (allLines.length - 1) * lineHeight + bestFontSize * 1.3 + 8
: bestFontSize * 1.3 + 8;
// 如果还是放不下,裁剪行数
if (totalHeight > height) {
const availableHeight = height - 8;
const maxLines = Math.max(1, Math.floor(availableHeight / lineHeight));
bestLines = allLines.slice(0, maxLines);
} else {
bestLines = allLines;
}
}
// 应用小标题的最小字号限制(仅限单行情况)
if (isShortText && bestLines.length === 1 && bestFontSize < 14) {
// 单行小标题:强制使用至少 14px
bestFontSize = 14;
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
bestLines = this.wrapText(ctx, text, width - 4);
}
// 绘制文字(确保不超出 bbox 高度)
ctx.fillStyle = '#000';
ctx.textBaseline = 'top';
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
const lineHeight = bestFontSize * 1.5; // 行间距 1.5
bestLines.forEach((line, i) => {
const lineY = y + 4 + i * lineHeight; // 上边距 4px
// 所有已经过裁剪的行都应该绘制
// 因为 bestLines 已经在上面被裁剪到只包含能完整显示的行
ctx.fillText(line, x + 2, lineY);
});
}
/**
* 使用文本自适应算法绘制文本(新算法)
* 策略:
* 1. 尽可能保证和原来的 bbox 块高度一致
* 2. 尽可能放下所有的内容
* 3. 在宽度和高度之间进行平衡,从而尽可能放大字体,但在 bbox 的框架内
* @param {number} suggestedFontSize - 可选的建议字号(来自预处理)
*/
drawPlainTextWithFitting(ctx, text, x, y, width, height, isShortText = false, suggestedFontSize = null) {
try {
// 判断是否为 CJK 语言
const isCJK = /[\u4e00-\u9fa5]/.test(text);
const lineSkip = isCJK ? 1.5 : 1.3; // 使用统一的行距
// 内边距对小bbox减少padding避免裁剪
const paddingTop = height < 20 ? 0.5 : 2; // 小bbox<20px0.5px padding
const paddingX = 2;
const availableHeight = height - paddingTop * 2; // 上下都留边距
const availableWidth = width - paddingX * 2;
// 字号范围:基于 bbox 高度估算
// 假设单行文本,字号约为 bbox 高度的 80%
const estimatedSingleLineFontSize = height * 0.8;
// 最小字号动态调整基于bbox高度
// 对于小bbox高度<20px允许使用更小的字号以避免裁剪
// 对于正常bbox使用10px/8px以平衡可读性和容纳率
let minFontSize;
if (height < 20) {
minFontSize = Math.max(6, height * 0.35); // 小bbox最小6px, 35%系数
} else {
minFontSize = isShortText ? 10 : 8; // 正常bbox10px/8px降低阈值
}
// 最大字号:不超过单行估算值的 1.5 倍
const maxFontSize = Math.min(estimatedSingleLineFontSize * 1.5, height * 1.2);
// 检查文本是否包含换行符
const hasNewlines = text.includes('\n');
const textLength = text.length;
// console.log(`[TextFitting] 开始: "${text.substring(0, 30)}..." bbox=${width.toFixed(0)}x${height.toFixed(0)}, 字号范围=${minFontSize.toFixed(1)}-${maxFontSize.toFixed(1)}px, 文本长度=${textLength}, 有换行=${hasNewlines}`);
// 尝试不同的宽度因子(从 1.0 开始,优先使用全宽)
// 如果文本很短或有原始换行符,只使用全宽
const widthFactors = (textLength < 20 || hasNewlines)
? [1.0]
: [1.0, 0.95, 0.90, 0.85, 0.80, 0.75, 0.70];
let bestSolution = null; // { fontSize, widthFactor, lines }
// 对每个宽度因子,使用二分查找找到最大可用字号
for (const widthFactor of widthFactors) {
const effectiveWidth = availableWidth * widthFactor;
// 二分查找最大字号
let low = minFontSize;
let high = maxFontSize;
let foundFontSize = null;
let foundLines = null;
while (high - low > 0.5) { // 精度 0.5px
const mid = (low + high) / 2;
// 测试这个字号是否能装下
ctx.font = `${mid}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
const lines = this.wrapText(ctx, text, effectiveWidth);
const lineHeight = mid * lineSkip;
// 计算总高度:最后一行不需要完整行高
const totalHeight = lines.length > 0
? (lines.length - 1) * lineHeight + mid
: 0;
if (totalHeight <= availableHeight) {
// 能装下,尝试更大的字号
foundFontSize = mid;
foundLines = lines;
low = mid;
} else {
// 装不下,尝试更小的字号
high = mid;
}
}
// 如果找到了可行解,且比当前最优解更好(字号更大)
if (foundFontSize && (!bestSolution || foundFontSize > bestSolution.fontSize)) {
bestSolution = {
fontSize: foundFontSize,
widthFactor: widthFactor,
lines: foundLines
};
}
}
// 如果找到了最优解,绘制
if (bestSolution) {
const { fontSize, widthFactor, lines } = bestSolution;
const lineHeight = fontSize * lineSkip;
ctx.font = `${fontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
ctx.fillStyle = '#000';
ctx.textBaseline = 'top';
// 计算水平偏移(如果使用了缩小的宽度,左对齐)
const xOffset = 0; // 始终左对齐
// 计算总高度并垂直居中
const totalHeight = lines.length > 0
? (lines.length - 1) * lineHeight + fontSize
: 0;
const yOffset = (availableHeight - totalHeight) / 2;
lines.forEach((line, i) => {
const lineY = y + paddingTop + yOffset + i * lineHeight;
ctx.fillText(line, x + paddingX + xOffset, lineY);
});
// 显示前3行内容用于调试已注释以减少日志
// const previewLines = lines.slice(0, 3).map(l => `"${l}"`).join(', ');
// console.log(`[TextFitting] ✓ 成功: "${text.substring(0, 30)}..." 字号=${fontSize.toFixed(1)}px, 行数=${lines.length}, 宽度=${(widthFactor*100).toFixed(0)}%, bbox高=${height.toFixed(1)}px, 实际高=${totalHeight.toFixed(1)}px`);
// console.log(`[TextFitting] 前3行: ${previewLines}`);
return;
}
// 如果所有方案都失败,使用最小字号强制绘制(裁剪行数)
console.warn(`[TextFitting] ⚠ 无法找到合适字号,使用最小字号: "${text.substring(0, 30)}..."`);
const fallbackFontSize = minFontSize;
const fallbackLineHeight = fallbackFontSize * lineSkip;
ctx.font = `${fallbackFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
ctx.fillStyle = '#000';
ctx.textBaseline = 'top';
const allLines = this.wrapText(ctx, text, availableWidth);
const maxLines = Math.floor(availableHeight / fallbackLineHeight);
const linesToDraw = allLines.slice(0, Math.max(1, maxLines));
linesToDraw.forEach((line, i) => {
const lineY = y + paddingTop + i * fallbackLineHeight;
ctx.fillText(line, x + paddingX, lineY);
});
console.warn(`[TextFitting] ✗ 裁剪: 字号=${fallbackFontSize.toFixed(1)}px, 绘制=${linesToDraw.length}/${allLines.length}`);
} catch (error) {
console.error('[PDFCompareView] 文本自适应渲染失败:', error);
// 最小回退渲染
ctx.font = '8px Arial, sans-serif';
ctx.fillStyle = '#000';
ctx.textBaseline = 'top';
const lines = this.wrapText(ctx, text, width - 4);
lines.forEach((line, i) => {
const lineY = y + 4 + i * 12;
if (lineY + 12 <= y + height) {
ctx.fillText(line, x + 2, lineY);
}
});
}
}
/**
* 绘制包含公式的文本HTML + KaTeX- 自适应字号版本
* @param {number} fontSize - 动态计算的字号
*/
drawTextWithFormulaInBoxAdaptive(text, x, y, width, height, pageNum = null, wrapperEl = null, isShortText = false, fontSize = 12) {
// 创建临时 DOM 元素来渲染公式
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
// 转换为 CSS 像素(逻辑像素)
tempDiv.style.left = `${x / this.dpr}px`;
tempDiv.style.top = `${y / this.dpr}px`;
tempDiv.style.width = `${width / this.dpr}px`;
// 设置高度限制
const targetHeightPx = height / this.dpr;
tempDiv.style.height = `${targetHeightPx}px`;
tempDiv.style.maxHeight = `${targetHeightPx}px`;
tempDiv.style.overflow = 'hidden'; // 隐藏超出部分
tempDiv.style.wordWrap = 'break-word';
tempDiv.style.overflowWrap = 'break-word';
// 使用传入的自适应字号
tempDiv.style.fontSize = `${fontSize}px`;
tempDiv.style.lineHeight = '1.5'; // 行间距 1.5(与 Canvas 一致)
tempDiv.style.padding = '4px 2px'; // 上下4px左右2px
tempDiv.style.paddingBottom = '4px'; // 确保底部也有足够空间
tempDiv.style.boxSizing = 'border-box';
tempDiv.style.pointerEvents = 'none';
tempDiv.style.color = '#000';
tempDiv.style.zIndex = '10';
tempDiv.style.fontFamily = 'sans-serif';
tempDiv.style.webkitFontSmoothing = 'antialiased';
tempDiv.setAttribute('data-pdf-compare', '1');
if (pageNum != null) tempDiv.setAttribute('data-page', String(pageNum));
// 渲染公式
const processedText = this.renderFormulasInText(text);
tempDiv.innerHTML = processedText;
// 添加到传入的段 wrapper相对定位的容器
const targetWrapper = wrapperEl || this.translationSegmentsContainer || document.getElementById('pdf-translation-segments') || this.translationCanvas?.parentElement;
if (targetWrapper) {
targetWrapper.appendChild(tempDiv);
// ✅ 修复公式超高问题:迭代缩小字号直到内容适配
const minFontSize = 6; // 最小字号
const fontSizeStep = 0.5; // 每次缩小 0.5px
let currentFontSize = fontSize;
let iterations = 0;
const maxIterations = 20; // 最多尝试 20 次
// 等待 KaTeX 渲染完成后检查高度
setTimeout(() => {
// 检查实际内容高度是否超出容器
while (tempDiv.scrollHeight > targetHeightPx && currentFontSize > minFontSize && iterations < maxIterations) {
currentFontSize -= fontSizeStep;
tempDiv.style.fontSize = `${currentFontSize}px`;
iterations++;
}
// 如果经过迭代后仍然超高,记录警告
if (tempDiv.scrollHeight > targetHeightPx) {
const overflowRatio = ((tempDiv.scrollHeight / targetHeightPx - 1) * 100).toFixed(1);
console.warn(
`[FormulaFitting] 公式内容超出bbox ${overflowRatio}%:`,
`scrollHeight=${tempDiv.scrollHeight.toFixed(1)}px,`,
`targetHeight=${targetHeightPx.toFixed(1)}px,`,
`最终字号=${currentFontSize.toFixed(1)}px`,
`(已达最小字号${minFontSize}px)`
);
} else if (iterations > 0) {
// 成功缩小到合适大小,记录调试信息
console.log(
`[FormulaFitting] 自动缩小字号: ${fontSize.toFixed(1)}px → ${currentFontSize.toFixed(1)}px`,
`(迭代${iterations}次)`
);
}
}, 10); // 等待 10ms 让 KaTeX 渲染完成
}
}
/**
* 绘制包含公式的文本HTML + KaTeX- 兼容旧接口
* @param {boolean} isShortText - 是否为短文本/小标题(会使用更大的字号)
*/
drawTextWithFormulaInBox(text, x, y, width, height, pageNum = null, wrapperEl = null, isShortText = false) {
// 根据 bbox 高度自适应字号(避免压住下一行)
const heightCss = height / this.dpr;
let fontSize;
if (isShortText) {
// 小标题:根据高度自适应,但最小 14px
fontSize = Math.max(Math.min(heightCss / 2.5, 18), 14); // 14-18px更保守的计算
} else {
// 常规文本:根据高度自适应,最小 6px
fontSize = Math.max(Math.min(heightCss / 3.5, 13), 6); // 6-13px更保守的计算
}
// 调用自适应版本
this.drawTextWithFormulaInBoxAdaptive(text, x, y, width, height, pageNum, wrapperEl, isShortText, fontSize);
}
/**
* 清理指定页的公式 DOM
*/
clearFormulaElementsForPage(pageNum) {
const wrapper = this.translationSegmentsContainer || document.getElementById('pdf-translation-segments') || this.translationCanvas?.parentElement;
if (!wrapper) return;
const list = wrapper.querySelectorAll('[data-pdf-compare="1"][data-page="' + String(pageNum) + '"]');
list.forEach(el => el.remove());
}
/**
* 清理所有由我们创建的公式 DOM
*/
clearAllFormulaElements() {
const wrapper = this.translationSegmentsContainer || document.getElementById('pdf-translation-segments') || this.translationCanvas?.parentElement;
if (!wrapper) return;
const list = wrapper.querySelectorAll('[data-pdf-compare="1"]');
list.forEach(el => el.remove());
}
// 仅在指定 wrapper 中清理指定页的公式 DOM
clearFormulaElementsForPageInWrapper(pageNum, wrapper) {
if (!wrapper) return;
const list = wrapper.querySelectorAll('[data-pdf-compare="1"][data-page="' + String(pageNum) + '"]');
list.forEach(el => el.remove());
}
/**
* 渲染文本中的公式(优化版:优先使用模块)
*/
renderFormulasInText(text) {
// 优先使用 TextFittingAdapter 的公式渲染
if (this.textFittingAdapter && typeof this.textFittingAdapter.renderFormulasInText === 'function') {
return this.textFittingAdapter.renderFormulasInText(text);
}
// 回退方案:使用本地实现
if (!this._formulaCache) {
this._formulaCache = new Map();
}
if (this._formulaCache.has(text)) {
return this._formulaCache.get(text);
}
if (typeof window.renderMathInElement === 'function') {
// 预处理公式修复常见的非标准LaTeX命令
let processedText = text;
// 将 \plus 替换为 +\plus 不是标准LaTeX命令
processedText = processedText.replace(/\\plus(?![a-zA-Z])/g, '+');
const tempContainer = document.createElement('div');
tempContainer.textContent = processedText;
try {
window.renderMathInElement(tempContainer, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false }
],
throwOnError: false,
strict: false
});
const result = tempContainer.innerHTML;
// 缓存结果(最多缓存 500 条)
if (this._formulaCache.size < 500) {
this._formulaCache.set(text, result);
}
return result;
} catch (e) {
// 只在首次失败时打印日志
if (!this._katexWarned) {
console.warn('[PDFCompareView] KaTeX 渲染失败:', e);
this._katexWarned = true;
}
return text;
}
} else {
// 只警告一次
if (!this._katexUnavailableWarned) {
console.warn('[renderFormulasInText] renderMathInElement 不可用');
this._katexUnavailableWarned = true;
}
return text;
}
}
/**
* 将文字按宽度换行(智能换行,支持中英文)
*/
wrapText(ctx, text, maxWidth) {
if (!text) return [];
const lines = [];
let currentLine = '';
// 先按自然断句分段(句号、问号、感叹号、逗号等)
const segments = text.split(/([。?!,、;:\n])/);
for (let segment of segments) {
if (!segment) continue;
// 如果是标点符号,直接加到当前行
if (/^[。?!,、;:]$/.test(segment)) {
currentLine += segment;
continue;
}
// 如果是换行符,强制换行
if (segment === '\n') {
if (currentLine) {
lines.push(currentLine);
currentLine = '';
}
continue;
}
// 按字符逐个添加
for (let i = 0; i < segment.length; i++) {
const char = segment[i];
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine.length > 0) {
// 当前行已满,换行
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
/**
* 根据类型获取颜色
*/
getTypeColor(type, alpha = 0.3) {
switch (type) {
case 'table':
return `rgba(204, 204, 0, ${alpha})`;
case 'image':
return `rgba(153, 255, 51, ${alpha})`;
case 'title':
return `rgba(102, 102, 255, ${alpha})`;
case 'text':
return `rgba(153, 0, 76, ${alpha})`;
default:
return `rgba(255, 235, 59, ${alpha})`;
}
}
/**
* 渲染所有 bbox激活可见性
*/
renderAllBboxes(pageNum) {
const currentPageIndex = pageNum - 1;
const ctx = this.originalOverlayContext;
// 清空
ctx.clearRect(0, 0, this.originalOverlay.width, this.originalOverlay.height);
// 获取当前页的所有内容块(只显示文本类型)
const pageItems = this.contentListJson.filter(item =>
item.page_idx === currentPageIndex && item.type === 'text'
);
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = this.originalOverlay.width / BBOX_NORMALIZED_RANGE;
const scaleY = this.originalOverlay.height / BBOX_NORMALIZED_RANGE;
// 绘制所有文本 bbox紫红色边框更明显
pageItems.forEach((item, idx) => {
if (!item.bbox) return;
const bbox = item.bbox;
const x = bbox[0] * scaleX;
const y = bbox[1] * scaleY;
const w = (bbox[2] - bbox[0]) * scaleX;
const h = (bbox[3] - bbox[1]) * scaleY;
// 使用紫红色text 类型的颜色),加深透明度
ctx.strokeStyle = 'rgb(153, 0, 76)';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.7; // 从 0.3 改为 0.7,更明显
ctx.strokeRect(x, y, w, h);
ctx.globalAlpha = 1.0;
});
}
/**
* 绑定左边 bbox 的点击事件
*/
bindBboxClick() {
// 事件绑定在每个段的 overlay 上(见 createSegmentDom
}
/**
* 连续模式:处理每页 overlay 的点击
*/
/**
* 连续模式:高亮左右对应 bbox在长画布上仅该页区域
*/
highlightBboxPairContinuous(item, pageInfo) {
// 已改为分段高亮,保留签名以兼容但不再使用
}
// 段 overlay 点击命中测试并高亮左右
async onSegmentOverlayClick(e, seg) {
const overlay = e.currentTarget;
const rect = overlay.getBoundingClientRect();
const sx = overlay.width / overlay.clientWidth;
const sy = overlay.height / overlay.clientHeight;
const x = (e.clientX - rect.left) * sx;
const y = (e.clientY - rect.top) * sy;
// 找页
const page = seg.pages.find(p => y >= p.yInSegPx && y <= p.yInSegPx + p.height);
if (!page) return;
const pageNum = page.pageNum;
const items = this.contentListJson.filter(it => it.page_idx === pageNum - 1 && it.type === 'text');
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = page.width / BBOX_NORMALIZED_RANGE;
const scaleY = page.height / BBOX_NORMALIZED_RANGE;
for (const it of items) {
if (!it.bbox) continue;
const b = it.bbox;
const bx = b[0] * scaleX;
const by = b[1] * scaleY + page.yInSegPx;
const bw = (b[2] - b[0]) * scaleX;
const bh = (b[3] - b[1]) * scaleY;
if (x >= bx && x <= bx + bw && y >= by && y <= by + bh) {
// 重绘本段 overlays
await this.renderSegmentOverlays(seg);
// 高亮左右
const drawHL = (ctx) => {
ctx.strokeStyle = 'rgb(255, 0, 0)';
ctx.lineWidth = 3;
ctx.globalAlpha = 0.8;
ctx.strokeRect(bx, by, bw, bh);
ctx.globalAlpha = 1.0;
};
drawHL(seg.left.overlayCtx);
drawHL(seg.right.overlayCtx);
break;
}
}
}
/**
* 高亮左右对应的 bbox
*/
highlightBboxPair(index, pageItems) {
const BBOX_NORMALIZED_RANGE = 1000;
const scaleX = this.originalOverlay.width / BBOX_NORMALIZED_RANGE;
const scaleY = this.originalOverlay.height / BBOX_NORMALIZED_RANGE;
const scaleXTrans = this.translationOverlay.width / BBOX_NORMALIZED_RANGE;
const scaleYTrans = this.translationOverlay.height / BBOX_NORMALIZED_RANGE;
const item = pageItems[index];
if (!item || !item.bbox) return;
// 高亮左边原始 bbox
const ctx = this.originalOverlayContext;
const bbox = item.bbox;
const x = bbox[0] * scaleX;
const y = bbox[1] * scaleY;
const w = (bbox[2] - bbox[0]) * scaleX;
const h = (bbox[3] - bbox[1]) * scaleY;
// 重绘所有 bbox清除之前的高亮
this.renderAllBboxes(this.currentPage);
// 高亮当前点击的 bbox左边
ctx.strokeStyle = 'rgb(255, 0, 0)'; // 红色高亮
ctx.lineWidth = 3;
ctx.globalAlpha = 0.8;
ctx.strokeRect(x, y, w, h);
ctx.globalAlpha = 1.0;
// 重新渲染右边的翻译层(这样翻译文本不会消失)
this.renderTranslationOverlay(this.currentPage);
// 在翻译层上叠加高亮边框
const ctxTrans = this.translationOverlayContext;
const xTrans = bbox[0] * scaleXTrans;
const yTrans = bbox[1] * scaleYTrans;
const wTrans = (bbox[2] - bbox[0]) * scaleXTrans;
const hTrans = (bbox[3] - bbox[1]) * scaleYTrans;
ctxTrans.strokeStyle = 'rgb(255, 0, 0)'; // 红色高亮
ctxTrans.lineWidth = 3;
ctxTrans.globalAlpha = 0.8;
ctxTrans.strokeRect(xTrans, yTrans, wTrans, hTrans);
ctxTrans.globalAlpha = 1.0;
}
/**
* 获取类型的边框颜色
*/
getTypeStrokeColor(type) {
switch (type) {
case 'table':
return 'rgb(204, 204, 0)';
case 'image':
return 'rgb(153, 255, 51)';
case 'title':
return 'rgb(102, 102, 255)';
case 'text':
return 'rgb(153, 0, 76)';
default:
return 'rgb(251, 192, 45)';
}
}
/**
* 绑定滚动联动
*/
bindScrollSync() {
const originalScroll = document.getElementById('pdf-original-scroll');
const translationScroll = document.getElementById('pdf-translation-scroll');
if (!originalScroll || !translationScroll) return;
let isSyncing = false;
let scrollSyncRaf = null;
let renderDebounceTimer = null;
// 使用 requestAnimationFrame + 防抖优化滚动性能
const syncScroll = (source, target) => {
if (isSyncing) return;
if (scrollSyncRaf) {
cancelAnimationFrame(scrollSyncRaf);
}
scrollSyncRaf = requestAnimationFrame(() => {
isSyncing = true;
target.scrollTop = source.scrollTop;
target.scrollLeft = source.scrollLeft;
// 立即取消同步标志
requestAnimationFrame(() => {
isSyncing = false;
});
});
// 延迟渲染可见段防抖优化150ms内只触发一次
if (this.mode === 'continuous') {
if (renderDebounceTimer) {
clearTimeout(renderDebounceTimer);
}
renderDebounceTimer = setTimeout(() => {
this.renderVisibleSegments(source);
}, 150);
}
};
// 滚动联动(使用 passive 优化)
originalScroll.addEventListener('scroll', () => syncScroll(originalScroll, translationScroll), { passive: true });
translationScroll.addEventListener('scroll', () => syncScroll(translationScroll, originalScroll), { passive: true });
}
/**
* 检测是否需要自动加载下一页
*/
checkAutoLoadNextPage(scrollElement) {
// 单页模式下才启用自动翻页加载
if (this.mode === 'continuous') return;
const scrollTop = scrollElement.scrollTop;
const scrollHeight = scrollElement.scrollHeight;
const clientHeight = scrollElement.clientHeight;
// 滚动到距离底部 100px 以内时,自动加载下一页
if (scrollHeight - scrollTop - clientHeight < 100) {
if (this.currentPage < this.totalPages && !this.isLoadingPage) {
this.isLoadingPage = true;
setTimeout(() => {
this.renderPage(this.currentPage + 1).then(() => {
this.isLoadingPage = false;
});
}, 100);
}
}
}
/**
* 绑定事件
*/
bindEvents() {
// 退出全屏对照模式
document.getElementById('pdf-exit-fullscreen')?.addEventListener('click', () => {
// 恢复浮动图标
this.showFloatingIcons();
// 切换回其他标签(优先切换到 OCR 标签,避免没有翻译内容时报错)
if (typeof window.showTab === 'function') {
// 如果有翻译内容,切换到翻译标签;否则切换到 OCR 标签
const hasTranslation = window.data && window.data.translation && window.data.translation.trim() !== '';
window.showTab(hasTranslation ? 'translation' : 'ocr');
}
});
// 上一页(连续模式下滚动定位;单页模式下重新渲染)
document.getElementById('pdf-prev-page')?.addEventListener('click', () => {
if (this.mode === 'continuous') {
const current = this.getCurrentVisiblePageNum();
const target = Math.max(1, current - 1);
this.scrollToPage(target);
} else if (this.currentPage > 1) {
this.renderPage(this.currentPage - 1);
}
});
// 下一页
document.getElementById('pdf-next-page')?.addEventListener('click', () => {
if (this.mode === 'continuous') {
const current = this.getCurrentVisiblePageNum();
const target = Math.min(this.totalPages, current + 1);
this.scrollToPage(target);
} else if (this.currentPage < this.totalPages) {
this.renderPage(this.currentPage + 1);
}
});
// 放大/缩小:连续模式下重建长画布;单页模式维持原逻辑
document.getElementById('pdf-zoom-in')?.addEventListener('click', async () => {
this.scale = Math.min(this.scale + 0.25, 3.0);
document.getElementById('pdf-zoom-level').textContent = `${Math.round(this.scale * 100)}%`;
if (this.mode === 'continuous') {
await this.rebuildContinuousViewAndKeepScroll();
} else {
this.renderPage(this.currentPage);
}
});
document.getElementById('pdf-zoom-out')?.addEventListener('click', async () => {
this.scale = Math.max(this.scale - 0.25, 0.5);
document.getElementById('pdf-zoom-level').textContent = `${Math.round(this.scale * 100)}%`;
if (this.mode === 'continuous') {
await this.rebuildContinuousViewAndKeepScroll();
} else {
this.renderPage(this.currentPage);
}
});
// 导出保留原格式的译文PDF
document.getElementById('pdf-export-structured-translation')?.addEventListener('click', async () => {
await this.exportStructuredTranslation();
});
}
/**
* 重新构建连续模式视图,并尽量保持滚动位置
*/
async rebuildContinuousViewAndKeepScroll() {
const left = document.getElementById('pdf-original-scroll');
const right = document.getElementById('pdf-translation-scroll');
if (!left || !right) return;
// 记录滚动比例,尽量保持位置
const frac = left.scrollHeight > left.clientHeight ? left.scrollTop / (left.scrollHeight - left.clientHeight) : 0;
// 不需要等待旧长画布队列;分段渲染按段顺序进行
// 清空 overlay 公式 DOM
this.clearAllFormulaElements();
// 重建长画布
await this.renderAllPagesContinuous();
// 恢复滚动
const newTop = frac * (left.scrollHeight - left.clientHeight);
left.scrollTop = newTop;
right.scrollTop = newTop;
// 渲染当前可见页
await this.renderVisibleSegments(left);
}
/**
* 获取当前可见区所处的页号(连续模式)
*/
getCurrentVisiblePageNum() {
const scroller = document.getElementById('pdf-original-scroll');
if (!scroller || !this.pageInfos.length) return this.currentPage || 1;
const dpr = this.dpr;
const scrollTop = scroller.scrollTop * dpr;
for (const pi of this.pageInfos) {
if (pi.yOffset + pi.height > scrollTop) {
return pi.pageNum;
}
}
return this.pageInfos[this.pageInfos.length - 1].pageNum;
}
/**
* 连续模式:滚动到指定页
*/
scrollToPage(pageNum) {
if (!this.pageInfos || !this.pageInfos.length) return;
const pageInfo = this.pageInfos.find(p => p.pageNum === pageNum);
if (!pageInfo) return;
const cssTop = pageInfo.yOffset / this.dpr;
const originalScroll = document.getElementById('pdf-original-scroll');
const translationScroll = document.getElementById('pdf-translation-scroll');
if (originalScroll) originalScroll.scrollTop = cssTop;
if (translationScroll) translationScroll.scrollTop = cssTop;
// 进入视口后触发渲染
this.renderVisibleSegments(originalScroll);
}
/**
* 隐藏浮动图标(除了 chatbot
*/
hideFloatingIcons() {
// 延迟执行,确保 DOM 已完全加载
setTimeout(() => {
const iconsToHide = [
'toggle-immersive-btn', // 沉浸式模式按钮
'toc-float-btn' // 目录浮动按钮
];
console.log('[PDFCompareView] 🔍 开始隐藏浮动图标');
iconsToHide.forEach(id => {
const element = document.getElementById(id);
if (element) {
console.log(`[PDFCompareView] ✅ 找到图标 ${id},当前 display:`, element.style.display);
element.style.setProperty('display', 'none', 'important');
element.style.setProperty('visibility', 'hidden', 'important');
element.style.setProperty('opacity', '0', 'important');
element.style.setProperty('pointer-events', 'none', 'important');
console.log(`[PDFCompareView] ✔️ 已隐藏 ${id},新 display:`, element.style.display);
} else {
console.warn(`[PDFCompareView] ❌ 未找到图标 ${id}`);
}
});
}, 200);
}
/**
* 恢复浮动图标
*/
showFloatingIcons() {
const iconsToShow = [
'toggle-immersive-btn',
'toc-float-btn'
];
iconsToShow.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.style.removeProperty('display');
element.style.removeProperty('visibility');
element.style.removeProperty('opacity');
element.style.removeProperty('pointer-events');
}
});
}
/**
* HTML 转义
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 导出保留原格式的译文PDF
* 使用 pdf-lib 在原始PDF上覆盖翻译后的文本
*/
async exportStructuredTranslation() {
// 优先使用新模块
if (this.pdfExporter) {
await this.pdfExporter.exportStructuredTranslation(
this.originalPdfBase64,
this.translatedContentList,
typeof showNotification === 'function' ? showNotification : null
);
return;
}
// 回退:使用原有实现
try {
// 检查是否有翻译数据
if (!this.translatedContentList || this.translatedContentList.length === 0) {
if (typeof showNotification === 'function') {
showNotification('没有翻译内容可导出', 'warning');
}
return;
}
// 检查是否有原始PDF数据
if (!this.originalPdfBase64) {
if (typeof showNotification === 'function') {
showNotification('原始PDF数据不可用', 'error');
}
return;
}
// 显示进度提示
if (typeof showNotification === 'function') {
showNotification('正在生成译文PDF请稍候...', 'info');
}
// 动态加载 pdf-lib如果尚未加载
if (typeof PDFLib === 'undefined') {
console.log('[PDFCompareView] 正在加载 pdf-lib...');
await this.loadPdfLib();
}
const { PDFDocument, rgb, StandardFonts } = PDFLib;
// 加载原始PDF
const pdfBytes = this.base64ToUint8Array(this.originalPdfBase64);
const pdfDoc = await PDFDocument.load(pdfBytes);
// 注册 fontkit用于嵌入自定义字体
if (typeof fontkit !== 'undefined') {
pdfDoc.registerFontkit(fontkit);
console.log('[PDFCompareView] fontkit 已注册');
} else {
console.warn('[PDFCompareView] fontkit 未加载,无法嵌入自定义字体');
}
// 尝试嵌入中文字体从CDN加载思源黑体
let font = null;
try {
if (typeof fontkit === 'undefined') {
throw new Error('fontkit 未加载,无法嵌入中文字体');
}
console.log('[PDFCompareView] 正在加载中文字体...');
const fontBytes = await fetch('https://gcore.jsdelivr.net/npm/source-han-sans-cn@1.0.0/SourceHanSansCN-Normal.otf').then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.arrayBuffer();
});
font = await pdfDoc.embedFont(fontBytes);
console.log('[PDFCompareView] 中文字体加载成功');
} catch (fontError) {
console.error('[PDFCompareView] 中文字体加载失败:', fontError);
if (typeof showNotification === 'function') {
showNotification('中文字体加载失败无法导出PDF: ' + fontError.message, 'error');
}
throw fontError; // 中止导出
}
// 按页面分组翻译内容
const pageContentMap = new Map();
this.translatedContentList.forEach((item, idx) => {
if (item.type !== 'text' || !item.text || !item.bbox) return;
const pageIdx = item.page_idx !== undefined ? item.page_idx : 0;
if (!pageContentMap.has(pageIdx)) {
pageContentMap.set(pageIdx, []);
}
pageContentMap.get(pageIdx).push({ ...item, originalIndex: idx });
});
// 常量MinerU bbox 归一化范围固定为1000与canvas渲染逻辑一致
const BBOX_NORMALIZED_RANGE = 1000;
// 遍历每一页,覆盖翻译文本
for (const [pageIdx, items] of pageContentMap.entries()) {
if (pageIdx >= pdfDoc.getPageCount()) continue;
const page = pdfDoc.getPage(pageIdx);
const { width: pageWidth, height: pageHeight } = page.getSize();
// 计算缩放因子bbox归一化范围固定为1000
const scaleX = pageWidth / BBOX_NORMALIZED_RANGE;
const scaleY = pageHeight / BBOX_NORMALIZED_RANGE;
console.log(`[PDFCompareView] 页面 ${pageIdx}: PDF尺寸=${pageWidth.toFixed(2)}x${pageHeight.toFixed(2)}pt, 缩放比例=${scaleX.toFixed(3)}x${scaleY.toFixed(3)}`);
// 先用白色矩形覆盖原文(清除原文)
items.forEach(item => {
const bbox = item.bbox;
const x = bbox[0] * scaleX;
const y = pageHeight - (bbox[3] * scaleY); // PDF坐标系从下往上
const width = (bbox[2] - bbox[0]) * scaleX;
const height = (bbox[3] - bbox[1]) * scaleY;
// 绘制白色矩形覆盖原文
page.drawRectangle({
x: x,
y: y,
width: width,
height: height,
color: rgb(1, 1, 1), // 白色
});
});
// 绘制翻译文本使用与canvas渲染相同的文本适配算法
items.forEach(item => {
const bbox = item.bbox;
const text = item.text || '';
if (!text.trim()) return;
// 计算bbox在PDF坐标系中的位置和尺寸
const x = bbox[0] * scaleX; // 左边界
const boxWidth = (bbox[2] - bbox[0]) * scaleX;
const boxHeight = (bbox[3] - bbox[1]) * scaleY;
// bbox顶部和底部在PDF坐标系中的Y坐标
const bboxTop = pageHeight - (bbox[1] * scaleY);
const bboxBottom = pageHeight - (bbox[3] * scaleY);
// 判断是否为短文本与canvas渲染一致
const isShortText = text.length < 30;
// 使用与canvas相同的文本布局算法
const layout = this.calculatePdfTextLayout(font, text, boxWidth, boxHeight, isShortText);
const { fontSize, lines, lineHeight } = layout;
// 内边距与canvas渲染一致
const paddingTop = 2;
const paddingX = 2;
const availableHeight = boxHeight - paddingTop * 2;
// 计算总高度并垂直居中与canvas渲染一致
const totalHeight = lines.length > 0
? (lines.length - 1) * lineHeight + fontSize
: 0;
const yOffset = (availableHeight - totalHeight) / 2;
// 绘制每一行
lines.forEach((line, lineIdx) => {
// 计算baseline Y坐标从bbox底部开始加上padding和居中偏移
const lineY = bboxBottom + paddingTop + yOffset + (lineIdx * lineHeight);
// 确保不超出bbox范围
if (lineY < bboxBottom || lineY > bboxTop) return;
page.drawText(line, {
x: x + paddingX,
y: lineY,
size: fontSize,
font: font,
color: rgb(0, 0, 0), // 黑色文本
});
});
});
}
// 生成PDF
const modifiedPdfBytes = await pdfDoc.save();
// 创建Blob并下载
const blob = new Blob([modifiedPdfBytes], { type: 'application/pdf' });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
const filename = `translated_${timestamp}.pdf`;
// 使用FileSaver下载
if (typeof saveAs === 'function') {
saveAs(blob, filename);
if (typeof showNotification === 'function') {
showNotification('译文PDF导出成功', 'success');
}
} else {
// 后备方案:创建下载链接
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (typeof showNotification === 'function') {
showNotification('译文PDF导出成功', 'success');
}
}
} catch (error) {
console.error('[PDFCompareView] 导出PDF失败:', error);
if (typeof showNotification === 'function') {
showNotification('导出失败: ' + error.message, 'error');
}
}
}
/**
* 为PDF导出计算文本布局使用与canvas渲染相同的算法
* @param {Object} font - pdf-lib字体对象
* @param {string} text - 要渲染的文本
* @param {number} boxWidth - bbox宽度PDF坐标
* @param {number} boxHeight - bbox高度PDF坐标
* @param {boolean} isShortText - 是否为短文本
* @returns {Object} { fontSize, lines, lineHeight }
*/
calculatePdfTextLayout(font, text, boxWidth, boxHeight, isShortText = false) {
// 判断是否为 CJK 语言
const isCJK = /[\u4e00-\u9fa5]/.test(text);
const lineSkip = isCJK ? 1.25 : 1.15;
// 内边距与canvas渲染一致对小bbox减少padding
const paddingTop = boxHeight < 20 ? 0.5 : 2;
const paddingX = 2;
const availableHeight = boxHeight - paddingTop * 2;
const availableWidth = boxWidth - paddingX * 2;
// 字号范围与canvas渲染一致
const estimatedSingleLineFontSize = boxHeight * 0.8;
// 最小字号动态调整基于bbox高度
let minFontSize;
if (boxHeight < 20) {
minFontSize = Math.max(6, boxHeight * 0.35); // 小bbox最小6px
} else {
minFontSize = isShortText ? 10 : 8; // 正常bbox10px/8px
}
const maxFontSize = Math.min(estimatedSingleLineFontSize * 1.5, boxHeight * 1.2);
// 检查文本是否包含换行符
const hasNewlines = text.includes('\n');
const textLength = text.length;
// 尝试不同的宽度因子与canvas渲染一致
const widthFactors = (textLength < 20 || hasNewlines)
? [1.0]
: [1.0, 0.95, 0.90, 0.85, 0.80, 0.75, 0.70];
let bestSolution = null;
// 对每个宽度因子,使用二分查找找到最大可用字号
for (const widthFactor of widthFactors) {
const effectiveWidth = availableWidth * widthFactor;
// 二分查找最大字号
let low = minFontSize;
let high = maxFontSize;
let foundFontSize = null;
let foundLines = null;
while (high - low > 0.5) {
const mid = (low + high) / 2;
// 使用pdf-lib的字体测量API换行
const lines = this.wrapTextForPdf(font, text, effectiveWidth, mid);
const lineHeight = mid * lineSkip;
// 计算总高度与canvas渲染一致
const totalHeight = lines.length > 0
? (lines.length - 1) * lineHeight + mid
: 0;
if (totalHeight <= availableHeight) {
foundFontSize = mid;
foundLines = lines;
low = mid;
} else {
high = mid;
}
}
// 如果找到了可行解,且比当前最优解更好
if (foundFontSize && (!bestSolution || foundFontSize > bestSolution.fontSize)) {
bestSolution = {
fontSize: foundFontSize,
widthFactor: widthFactor,
lines: foundLines,
lineHeight: foundFontSize * lineSkip
};
}
}
// 如果找到了最优解,返回
if (bestSolution) {
return bestSolution;
}
// 后备方案使用最小字号与canvas渲染一致
const fallbackFontSize = minFontSize;
const fallbackLineHeight = fallbackFontSize * lineSkip;
const allLines = this.wrapTextForPdf(font, text, availableWidth, fallbackFontSize);
const maxLines = Math.floor(availableHeight / fallbackLineHeight);
const linesToDraw = allLines.slice(0, Math.max(1, maxLines));
return {
fontSize: fallbackFontSize,
lines: linesToDraw,
lineHeight: fallbackLineHeight,
widthFactor: 1.0
};
}
/**
* 为PDF文本换行使用pdf-lib字体测量
*/
wrapTextForPdf(font, text, maxWidth, fontSize) {
if (!text) return [];
const lines = [];
let currentLine = '';
// 先按自然断句分段与canvas wrapText一致
const segments = text.split(/([。?!,、;:\n])/);
for (let segment of segments) {
if (!segment) continue;
// 如果是标点符号,直接加到当前行
if (/^[。?!,、;:]$/.test(segment)) {
currentLine += segment;
continue;
}
// 如果是换行符,强制换行
if (segment === '\n') {
if (currentLine) {
lines.push(currentLine);
currentLine = '';
}
continue;
}
// 按字符逐个添加
for (let i = 0; i < segment.length; i++) {
const char = segment[i];
const testLine = currentLine + char;
const width = font.widthOfTextAtSize(testLine, fontSize);
if (width > maxWidth && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
/**
* 动态加载 pdf-lib 库和 fontkit
*/
async loadPdfLib() {
// 先加载 pdf-lib
if (typeof PDFLib === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://gcore.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js';
script.onload = () => {
console.log('[PDFCompareView] pdf-lib 加载成功');
resolve();
};
script.onerror = (error) => {
console.error('[PDFCompareView] pdf-lib 加载失败:', error);
reject(new Error('Failed to load pdf-lib library'));
};
document.head.appendChild(script);
});
}
// 再加载 fontkit用于嵌入自定义字体
if (typeof fontkit === 'undefined') {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://gcore.jsdelivr.net/npm/@pdf-lib/fontkit@1.1.1/dist/fontkit.umd.min.js';
script.onload = () => {
console.log('[PDFCompareView] fontkit 加载成功');
resolve();
};
script.onerror = (error) => {
console.warn('[PDFCompareView] fontkit 加载失败:', error);
// fontkit加载失败不应该阻止整个流程只是无法使用自定义字体
resolve();
};
document.head.appendChild(script);
});
}
}
/**
* 清理资源
*/
destroy() {
// 清理模块
if (this.segmentManager) {
this.segmentManager.destroy();
this.segmentManager = null;
}
if (this.textFittingAdapter) {
this.textFittingAdapter.clearCache();
}
if (this.pdfDoc) {
this.pdfDoc.destroy();
this.pdfDoc = null;
}
// 清理公式 DOM 元素
if (this.formulaElements) {
this.formulaElements.forEach(el => el.remove());
this.formulaElements = [];
}
this.canvas = null;
this.canvasContext = null;
this.overlayCanvas = null;
this.overlayContext = null;
this.contentListJson = null;
this.translatedContentList = null;
this.selectedItemIndex = null;
}
}
// 导出到全局
window.PDFCompareView = PDFCompareView;