paper-burner/js/history/pdf-compare-renderer.js

357 lines
12 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.

/**
* 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;
}