357 lines
12 KiB
JavaScript
357 lines
12 KiB
JavaScript
/**
|
||
* pdf-compare-renderer.js
|
||
* PDF 对照视图 - 文本渲染引擎模块
|
||
* 负责:文本自适应渲染、公式处理、白色背景绘制
|
||
*/
|
||
|
||
class PDFCompareRenderer {
|
||
constructor(view) {
|
||
this.view = view;
|
||
this.textFittingEngine = view.textFittingEngine;
|
||
}
|
||
|
||
/**
|
||
* 绘制页面的白色背景覆盖层(覆盖原始文字)
|
||
*/
|
||
renderPageBboxesToCtx(ctx, pageNum, yOffset, pageWidth, pageHeight) {
|
||
const pageItems = this.view.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 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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 分段:将译文绘制到指定 ctx,并将公式 DOM 插入到 wrapper
|
||
*/
|
||
async renderPageTranslationToCtx(ctx, wrapperEl, pageNum, yOffset, pageWidth, pageHeight) {
|
||
// 确保已经完成预处理
|
||
if (!this.view.hasPreprocessed) {
|
||
this.view.preprocessGlobalFontSizes();
|
||
}
|
||
|
||
const pageItems = this.view.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 层绘制白色背景(覆盖原始文字)
|
||
this.renderPageBboxesToCtx(ctx, pageNum, yOffset, pageWidth, pageHeight);
|
||
|
||
// 第二步:绘制翻译文本(在白色背景上)
|
||
pageItems.forEach((item) => {
|
||
const originalIdx = this.view.contentListJson.indexOf(item);
|
||
const translatedItem = this.view.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.view.globalFontSizeCache.get(originalIdx);
|
||
|
||
// 使用新的文本自适应引擎渲染
|
||
this.drawTextInBox(ctx, translatedItem.text, x, y, w, h, pageNum, wrapperEl, cachedInfo);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 在指定区域内绘制自适应大小的文字
|
||
*/
|
||
drawTextInBox(ctx, text, x, y, width, height, pageNum = null, wrapperEl = null, cachedInfo = null) {
|
||
if (!text) return;
|
||
|
||
// 检查是否为短文本/小标题(与 bbox 扩展判断保持一致)
|
||
const isShortText = text.length < 30;
|
||
|
||
// 暂时禁用公式渲染(用于测试)
|
||
// 所有文本都用 Canvas 渲染
|
||
|
||
// 使用预处理的字号信息或直接传递 cachedInfo
|
||
const suggestedFontSize = cachedInfo ? cachedInfo.estimatedFontSize : null;
|
||
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 = [];
|
||
|
||
// 从较大字号开始尝试,允许多行显示
|
||
for (let size = 16; size >= 6; size -= 1) {
|
||
ctx.font = `${size}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
const lines = this.wrapText(ctx, text, width - 4);
|
||
|
||
// 计算总高度
|
||
const lineHeight = size * 1.3;
|
||
const totalHeight = lines.length * lineHeight;
|
||
|
||
if (totalHeight <= height - 4) {
|
||
bestFontSize = size;
|
||
bestLines = lines;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 绘制最佳结果
|
||
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
ctx.fillStyle = '#000';
|
||
ctx.textBaseline = 'top';
|
||
|
||
const lineHeight = bestFontSize * 1.3;
|
||
|
||
bestLines.forEach((line, i) => {
|
||
const lineY = y + 4 + i * lineHeight;
|
||
// 所有已经过裁剪的行都应该绘制
|
||
// 因为 bestLines 已经在上面被裁剪到只包含能完整显示的行
|
||
ctx.fillText(line, x + 2, lineY);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 使用文本自适应算法绘制文本(新算法)
|
||
* @param {number} suggestedFontSize - 可选的建议字号(来自预处理)
|
||
*/
|
||
drawPlainTextWithFitting(ctx, text, x, y, width, height, isShortText = false, suggestedFontSize = null) {
|
||
try {
|
||
// 估算原始字体大小(基于 bbox 高度)
|
||
const estimatedFontSize = suggestedFontSize || (height * 0.90); // 平衡字号与内容完整性
|
||
|
||
// 判断是否为 CJK 语言(简单判断:检查文本中是否有中文字符)
|
||
const isCJK = /[\u4e00-\u9fa5]/.test(text);
|
||
|
||
// 使用文本自适应引擎计算最优缩放
|
||
const result = this.textFittingEngine.calculateOptimalScale(
|
||
text,
|
||
[x, y, x + width, y + height], // bbox
|
||
estimatedFontSize,
|
||
'Arial, "Microsoft YaHei", "SimHei", sans-serif',
|
||
isCJK,
|
||
{ firstLineIndent: false }
|
||
);
|
||
|
||
// 计算最终字体大小
|
||
let finalFontSize = estimatedFontSize * result.scale;
|
||
|
||
// 获取行距
|
||
const lineSkip = isCJK ? this.textFittingEngine.LINE_SKIP_CJK : this.textFittingEngine.LINE_SKIP_WESTERN;
|
||
|
||
// 动态缩放循环:不断尝试直到所有内容都装下(参考 其他开源项目)
|
||
const minReadableFontSize = 10; // 提高最小字号到 10px,保证可读性
|
||
const minFontSize = Math.max(estimatedFontSize * 0.2, minReadableFontSize); // 允许缩到 20%
|
||
|
||
// 确保至少从一个合理的字号开始尝试
|
||
if (finalFontSize < estimatedFontSize * 0.6) {
|
||
// 如果 TextFittingEngine 给出的字号太小,忽略它,从估算字号开始
|
||
finalFontSize = estimatedFontSize;
|
||
}
|
||
|
||
let attempts = 0;
|
||
const maxAttempts = 40; // 尝试次数
|
||
|
||
// 允许适度的底部溢出(像 其他开源项目 一样)
|
||
const allowedBottomOverflow = height * 0.15; // 允许 15% 底部溢出
|
||
|
||
while (finalFontSize >= minFontSize && attempts < maxAttempts) {
|
||
attempts++;
|
||
|
||
// 设置字体
|
||
ctx.font = `${finalFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
ctx.fillStyle = '#000';
|
||
ctx.textBaseline = 'top';
|
||
|
||
// 分行绘制
|
||
const lines = this.wrapText(ctx, text, width - 4);
|
||
const lineHeight = finalFontSize * lineSkip;
|
||
|
||
// 检查所有行是否都能装下
|
||
let allLinesFit = true;
|
||
let fittingLineCount = 0;
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const lineY = y + 2 + i * lineHeight;
|
||
// 允许适度溢出底部边界
|
||
if (lineY + lineHeight <= y + height + allowedBottomOverflow) {
|
||
fittingLineCount++;
|
||
} else {
|
||
allLinesFit = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 如果所有行都装下了,开始绘制
|
||
if (allLinesFit) {
|
||
lines.forEach((line, i) => {
|
||
const lineY = y + 2 + i * lineHeight; // 减小顶部 padding 从 4 到 2
|
||
ctx.fillText(line, x + 2, lineY);
|
||
});
|
||
|
||
// 调试信息
|
||
if (attempts > 1) {
|
||
console.log(`[TextFitting] 动态缩放成功: 文本="${text.substring(0, 30)}..." 尝试=${attempts}次, 最终字号=${finalFontSize.toFixed(1)}px, 行数=${lines.length}`);
|
||
}
|
||
return; // 成功,退出函数
|
||
}
|
||
|
||
// 装不下,减小字号重试
|
||
if (finalFontSize > estimatedFontSize * 0.6) {
|
||
finalFontSize -= estimatedFontSize * 0.04; // 减小 4%
|
||
} else {
|
||
finalFontSize -= estimatedFontSize * 0.08; // 加速减小 8%
|
||
}
|
||
}
|
||
|
||
// 如果循环结束还是装不下,优先保证字号可读性
|
||
// 使用最小可读字号绘制,即使会跳过部分行
|
||
const fallbackFontSize = Math.max(finalFontSize, minReadableFontSize);
|
||
ctx.font = `${fallbackFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
ctx.fillStyle = '#000';
|
||
ctx.textBaseline = 'top';
|
||
const lines = this.wrapText(ctx, text, width - 4);
|
||
const lineHeight = fallbackFontSize * lineSkip;
|
||
|
||
let drawnLines = 0;
|
||
let skippedLines = 0;
|
||
|
||
lines.forEach((line, i) => {
|
||
const lineY = y + 2 + i * lineHeight; // 减小顶部 padding 从 4 到 2
|
||
if (lineY + lineHeight <= y + height) {
|
||
ctx.fillText(line, x + 2, lineY);
|
||
drawnLines++;
|
||
} else {
|
||
skippedLines++;
|
||
}
|
||
});
|
||
|
||
if (skippedLines > 0) {
|
||
console.warn(`[TextFitting] 文本过长: "${text.substring(0, 30)}..." 尝试=${attempts}次, 字号=${fallbackFontSize.toFixed(1)}px, 绘制=${drawnLines}/${lines.length}行`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('[PDFCompareView] 文本自适应渲染失败:', error);
|
||
// 使用最小的回退渲染
|
||
ctx.font = '8px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", 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 < y + height) {
|
||
ctx.fillText(line, x + 2, lineY);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 文本换行(根据宽度)
|
||
*/
|
||
wrapText(ctx, text, maxWidth) {
|
||
const words = [];
|
||
|
||
// 对于中文,按字符分割;对于英文,按空格和标点分割
|
||
const isCJK = /[\u4e00-\u9fa5]/.test(text);
|
||
|
||
if (isCJK) {
|
||
// 中文按字符分割,保留标点
|
||
const segments = text.split(/([。?!,、;:\n])/);
|
||
|
||
for (let segment of segments) {
|
||
if (!segment) continue;
|
||
|
||
// 如果是单个标点符号,作为独立 token
|
||
if (/^[。?!,、;:]$/.test(segment)) {
|
||
words.push(segment);
|
||
} else if (segment === '\n') {
|
||
words.push('\n');
|
||
} else {
|
||
// 普通字符,逐字分割
|
||
words.push(...segment.split(''));
|
||
}
|
||
}
|
||
} else {
|
||
// 英文按空格和标点分割
|
||
const tokens = text.match(/\S+|\s+/g) || [];
|
||
words.push(...tokens);
|
||
}
|
||
|
||
const lines = [];
|
||
let currentLine = '';
|
||
let currentWidth = 0;
|
||
|
||
for (let word of words) {
|
||
// 处理换行符
|
||
if (word === '\n') {
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
currentLine = '';
|
||
currentWidth = 0;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 中文标点:尝试添加到当前行,允许略微超出
|
||
if (isCJK && /^[。?!,、;:]$/.test(word)) {
|
||
currentLine += word;
|
||
currentWidth += ctx.measureText(word).width;
|
||
continue;
|
||
}
|
||
|
||
const wordWidth = ctx.measureText(word).width;
|
||
|
||
// 检查是否需要换行
|
||
if (currentWidth + wordWidth > maxWidth && currentLine) {
|
||
lines.push(currentLine);
|
||
currentLine = word;
|
||
currentWidth = wordWidth;
|
||
} else {
|
||
currentLine += word;
|
||
currentWidth += wordWidth;
|
||
}
|
||
}
|
||
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
}
|
||
|
||
// 暴露到全局
|
||
if (typeof window !== 'undefined') {
|
||
window.PDFCompareRenderer = PDFCompareRenderer;
|
||
}
|