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