项目已经做的差不多了最后收尾阶段大家都在对用户界面进行优化以及最后的部署和测试我在测试的时候发现了一些pdf阅读页的问题我们的项目毕竟是以论文和agent为核心这一块的前端必须得优化好于是有了这篇博客的工作很多人第一次做 PDF 阅读页时都会先觉得这件事“应该不复杂”先把 PDF 用 canvas 画出来再盖一层透明文字层浏览器原生 selection 自然就能工作。这条路一开始确实能跑。但只要你开始真的拿它做阅读、划词、引用和翻译问题就会慢慢冒出来。这次在 PaperFlow 的论文阅读页里实际的问题不是“选不了”而是更烦的一类问题同一句话在小窗和大窗下高亮宽度不一致右边缘会出现一截一截、不贴文本的感觉左右边缘的竖排日期、水印也会被选进来单纯调整颜色、透明度和圆角后观感依然不理想。这说明问题已经不只是“高亮颜色好不好看”而是文字层本身的几何计算出现了偏差。1. 这次问题最后并没有落在 CSS 上如果只看表象最容易想到的做法是继续调整::selection 的颜色和透明度文字层的透明策略圆角、阴影等视觉样式。这些确实能影响观感但解决不了根本问题canvas 和文字层是否处于同一套坐标系浏览器排版后的文字宽度是否等于 PDF 原始文字宽度边缘水印是否被错误加入了可选文本范围。最后排查下来问题主要来自三个方面主阅读页在窗口变化时canvas 和文字层的显示尺寸同步不够稳定手写文字层中的 span 使用浏览器自然排版宽度与 PDF 原始宽度存在偏差左右边缘的竖排日期、水印也进入了文字层导致被浏览器当成正常文本选中。因此这次修复本质上并不是样式优化而是一次关于坐标、宽度和选区范围的整体修正。2. 为什么没有直接换回官方 TextLayer中间其实尝试过几种方案。第一种是继续调 CSS。这种方式虽然成本低但最多只能让问题“看起来不那么明显”无法真正解决错位。第二种是重新接回 pdf.js 官方 TextLayer。官方实现的几何计算更加完整也更符合标准。但当前阅读页已经集成了选区弹窗selectionPopover引用追加翻译功能AI 对话联动。如果直接切回完整 Viewer不仅仅是替换文字层而是会影响现有整套交互逻辑风险较大。最终选择了一条更加可控的路线固定逻辑页宽将页面拆分为 shell、zoom、content 三层结构保留现有手写文字层利用 PDF 原始宽度校正文字显示宽度过滤左右边缘竖排水印。虽然不是最标准的方案但更适合当前项目阶段。3. 先解决坐标系问题真正开始修复时我们并没有直接修改高亮而是先统一页面内部坐标系。阅读页引入了固定逻辑页宽const PDF_LOGICAL_PAGE_WIDTH 920;同时根据逻辑宽度重新计算 viewport 和显示缩放比例。核心逻辑是const PDF_LOGICAL_PAGE_WIDTH 920;const logicalScale PDF_LOGICAL_PAGE_WIDTH / baseViewport.width;const logicalViewport page.getViewport({ scale: logicalScale });const displayZoom Math.min(1, Math.max(0.4, availableWidth / logicalViewport.width));这样做的目的并不是固定页面显示大小而是让canvas 与文字层共享同一套内部坐标窗口变化时只改变显示缩放页面内部几何计算保持稳定。这一步虽然不能彻底解决问题但为后续所有修复提供了统一基础。4. 将阅读页拆成三层结构随后我们重新整理了页面结构。主阅读区域被拆成shellzoomcontent看起来只是多包了几层 div但实际意义很大。以前页面尺寸变化时页面布局变化canvas 尺寸变化文字层尺寸变化全部同时发生。现在则变成content 保存逻辑尺寸zoom 负责显示缩放shell 负责页面布局和滚动。这样内部坐标能够保持稳定窗口变化带来的影响也被控制在更小范围内。5. 真正解决问题的是宽度校正经过前面的调整后高亮已经比之前稳定许多但右边缘仍然存在偏差。进一步排查发现问题来自浏览器自然排版宽度。PDF 中每段文字都有自己的原始宽度信息但浏览器渲染时字体替换缩放比例排版细节都会导致最终显示宽度与 PDF 原始宽度不完全一致。因此最后采用了宽度校正方案。简单来说读取 PDF 原始文字宽度获取当前 DOM 实际宽度计算比例使用 scaleX 对文字进行微调。核心逻辑如下const expectedWidth it.width * logicalScale;const measuredWidth span.getBoundingClientRect().width;const scaleX expectedWidth / measuredWidth;只有当误差达到一定程度时才会执行校正并且限制缩放范围避免出现异常显示。同时将文字层 span 调整为 block 布局使宽度计算更加稳定。这一部分才是真正让高亮重新贴合正文的关键。6. 过滤左右边缘水印正文选区修复后又出现了新的需求用户希望 PDF 两侧的日期和水印不要再被选中。最简单的办法当然是直接过滤所有边缘文本。但这样会误伤页码边栏说明靠近边缘的正文内容。因此最终采用了更精细的判断规则。只有同时满足以下条件的文本才会被过滤文本长度达到一定程度文本方向接近竖排位于页面左右边缘区域。如下const rotation Math.atan2(b, a);const isVertical Math.abs(Math.cos(rotation)) 0.35;const edgeThreshold Math.max(36, input.pageWidth * 0.08);const isOnLeftEdge x edgeThreshold;const isOnRightEdge x input.pageWidth - edgeThreshold;return isOnLeftEdge || isOnRightEdge;这样可以做到水印不再进入选区页码仍然保留正常正文内容不受影响。7. 保留现有交互体系这次还有一个比较重要的取舍。我们只修文字层不重写交互层。因此selectionPopover 继续保留引用功能继续保留翻译功能继续保留AI 对话联动继续保留。这样可以把问题控制在文字层范围内避免一次修改牵动整个阅读页架构。对于仍在快速迭代的项目来说这种渐进式修复往往更加稳妥。8. 修复效果验证完成开发后我们分别从三个方面进行了验证文字层逻辑测试重点验证宽度计算是否正确旋转文本判断是否正确水印过滤规则是否生效。页面结构测试重点验证固定逻辑尺寸是否保留shell、zoom、content 三层结构是否正常工作页面缩放后文字层是否仍能保持同步。选区样式测试重点验证高亮样式是否正确透明文字层是否正常新增页面结构是否影响选区渲染。最终验收主要关注两个实际效果同一段文字在不同窗口尺寸下高亮是否仍然贴合正文左右边缘竖排水印是否已经无法被选中。经过多轮测试后这两个问题都得到了明显改善。9. 这次修复中踩过的坑回头总结这次最容易踩的几个坑分别是第一把问题误认为纯 CSS 问题。实际上颜色、透明度和圆角只能改善视觉效果解决不了文字层几何偏差。第二以为固定页面尺寸就能解决所有问题。如果浏览器排版宽度与 PDF 原始宽度不同误差仍然会存在。第三对边缘文本进行粗暴过滤。这样虽然能去掉水印但也容易误伤正常内容。第四过早重构整个阅读器体系。为了修复一个高亮问题而重做整套阅读页架构风险远远大于收益。10. 回头看这次方案最核心的价值不是完美而是收得住如果只追求最标准的实现方式直接接回官方 TextLayer 确实很有吸引力。但真实项目开发中很多选择并不是看谁最标准而是谁在当前阶段最可控。最终我们收敛出的方案是固定逻辑页宽分离 shell、zoom、content 三层结构保留现有手写文字层利用 PDF 原始宽度校正浏览器排版宽度精确过滤左右边缘竖排水印。它并不是一次大规模重构但解决了用户最关心的问题正文高亮更加贴合文本不同窗口尺寸下显示更加稳定水印不再干扰选区原有引用、翻译和 AI 对话功能全部保留。后续如果继续迭代我们计划进一步拆分文字层相关逻辑降低阅读页组件复杂度等阅读页整体架构更加稳定后再评估是否重新接入官方 TextLayer。