HiPDF 在线 PDF 编辑器面试手册

# HiPDF 在线 PDF 编辑器面试手册

# 使用方法

先读 项目介绍 和 前端职责与协作边界,再按 PDF 编辑、AI 翻译、协同与性能查阅内容。面试前用 重点问题速查 定位答案,术语不熟悉时查看 技术术语速查。

# 事实边界

PDFCore、HiPage、Block、RenderClassFactory 等是项目内部类或 SDK 接口,不是浏览器标准 API。协作锁由前端记录状态,通过 WebSocket 广播,并在其他客户端限制交互。失败恢复与消息去重等内容属于改进建议,尚未确认为已实现。

性能数字需区分特定条件下的测量结果与设计目标。

# 技术栈与项目分工

技术或模块 在 HiPDF 中负责什么
TypeScript (opens new window) 编写前端业务逻辑,定义文档、注释与消息类型,对接 PDFCore 接口
Vue (opens new window) 构建工具栏、输入框、翻译面板、评论列表与用户交互
Pinia (opens new window) 管理当前文档、页面、编辑模式、活动对象、工具样式与评论状态
Canvas (opens new window) 显示 PDF 页面的像素与交互层
Fabric.js (opens new window) 用于注释层,管理矩形、文字批注、路径等对象,提供选中、拖动、缩放和事件
PDFCore 以 WebAssembly (opens new window) 形式运行,负责解析文档、分析段落、计算光标与文本边界、渲染页面、修改并保存 PDF
REST API (opens new window) 保存、查询、更新、删除在线批注和评论
WebSocket (opens new window) 在协作同一份文档的用户之间,实时传递批注、评论、锁和成员状态

WASM 是什么

WASM 是 WebAssembly 的简称,是浏览器可以执行的编译产物格式,常见文件后缀为 .wasm。PDFCore 是团队提供的 PDF 处理引擎,以 WASM 形式在浏览器中运行。前端使用 TypeScript 编写调用逻辑,通过 PDFCore 暴露的接口处理文档;TypeScript 代码编译为 JavaScript 后在浏览器中执行。WASM 不会自动把计算放到后台线程,是否使用 Worker 是另一件事。

# 第一部分 项目介绍与架构

# 项目介绍

HiPDF 是一个在线 PDF 文档处理项目,本手册聚焦三个主要模块。PDF 编辑与注释支持修改原有文字和图片,以及添加高亮、图形、签名等批注;AI 翻译回填把文档按段落翻译,尽量保留原位置与样式,生成可下载的译文 PDF;协同注释与评论让分享文档的多人实时查看、修改批注并讨论。

前端使用 TypeScript 开发,Vue 和 Pinia 管理界面与状态;原有文字和图片通过 PDFCore 识别并编辑,Fabric.js 只负责注释对象的选中、拖动和绘制。WebAssembly 版 PDFCore 处理引擎负责解析、渲染与保存 PDF。在线批注通过 REST API 保存,通过 WebSocket 通知其他用户。项目的难点主要是画布坐标与 PDF 坐标一致、译文回填后尽量保持排版,以及多人修改时避免相互覆盖。

# 前端职责与协作边界

前端负责编辑工具与画布交互、PDFCore 接口对接、翻译流程与回填调用,以及批注和评论的在线同步。PDF 解析、布局、修改与保存能力由 PE 底层团队提供的 PDFCore 承担,语言转换由翻译 API 完成。前端通过接口保存在线数据,通过 WebSocket 同步界面;对象级编辑锁由前端记录和控制,服务端负责权限校验与数据持久化。

# 六层架构与双层画布

Vue 组件:AnnotateTools、EditInput、AITranslator、评论面板
    ↓ 用户操作
Pinia:editPDF、annotateData、commentData、各工具 Store
    ↓ 调用业务对象
业务交互:原文编辑调用 PDFCore;注释交互使用 Fabric
    ↓ 定位到对应页面
HiPage / HiPageWASMv2:页面尺寸、画布与页面生命周期
    ↓ 编辑或查询文档
PDFCore 封装:PDFDocument、Page、Block、AnnotateManager
    ↓ 调用底层能力
PDFCore.wasm:解析、渲染、布局分析、修改与保存
1
2
3
4
5
6
7
8
9
10
11

注释模式采用双层画布:底层显示 PDFCore 渲染的 PDF 页面,上层是透明的 Fabric 画布,显示可选中、可拖动的批注对象。两层必须共用页面尺寸、缩放和旋转信息,否则会出现看得见却点不准、批注随缩放漂移的问题。

原文编辑不经过 Fabric 层:前端直接调用 PDFCore 识别文本块和图片块,通过 PDFCore 接口修改内容,再重新渲染页面。

三个模块共用文档实例、页面管理和坐标转换能力:PDF 编辑与注释模块支持修改原有文字、图片,以及添加和编辑批注;AI 翻译模块提取段落、调用翻译服务,再通过 PDFCore 将译文回填到文档对应位置;协同模块通过 API 保存批注和评论,通过 WebSocket 将变更同步给其他用户。编辑、添加批注或翻译后的 PDF 文档,最终都通过 PDFCore 的底层接口生成并导出。

# 为什么选择 PDFCore,怎样与前端配合

项目需要修改 PDF 原有文字、图片并导出文件,因此选择 PE 底层团队提供的 PDFCore,复用客户端已有的解析、编辑和保存能力。前端负责业务流程、用户交互和 PDFCore 接口对接。

PDF.js (opens new window) 主要用于解析和展示 PDF,也支持填写表单、添加部分类型的注释,并通过 saveDocument() (opens new window) 保存这些修改。它本身不提供通用的原文编辑能力,例如修改已有正文、替换原有图片和调整段落排版。HiPDF 的原文编辑与译文回填由 PDFCore 提供这些能力。

场景 具体做法 优势与代价
批注编辑 Fabric 管理批注的绘制、选中、拖动和缩放;保存前将批注转换为 PDFCore 接受的注释数据并添加到文档 工具交互开发方便;要维护两种对象表示,坐标、样式与外观必须双向对齐
原文编辑 前端调用 PDFCore 修改文字、图片或段落,并重新渲染页面 文档修改后及时更新显示;需要对接光标、布局与编辑接口

两种编辑完成后,都通过 PDFCore 的底层保存接口生成包含修改内容的 PDF 文件,再由前端触发下载。AI 翻译回填后的文档也使用这套保存与导出流程。

# 第二部分 PDF 编辑与注释

这一模块解决怎样在浏览器里看清 PDF、准确编辑内容和添加批注,并把修改保存回文件。下面按加载渲染、坐标处理、注释工具、正文与图片编辑、撤销和字体展开。

# 文档加载与保存

# 从文件到可见页面

  1. PDFCoreLoader / usePDFCoreLoader 加载 PDFCore.wasm,建立 DocGlobals 全局上下文和 PDFDocument。
  2. select(file, password) 打开文档,底层调用 IPDFDocumentOpen(doc, stream, password) 解析 PDF;设置了打开密码的文档需要传入正确密码。
  3. doc.GetPages() 获取页面。PageDrawer 为页面建立 Canvas、Page、CoordinateConverter、Block 和 AnnotateManager。
  4. PDFCore 把页面内容和已有注释渲染成像素,前端把结果放到 Canvas;注释模式再由 Fabric 管理上层的批注交互。

底层光栅化调用链如下。光栅化就是把文档中的文字、矢量和图片转成用于显示的像素。

PDFFactory.CreateRender()
  → render.CreatePixmap()
  → render.RenderPage()
  → render.RenderPageAnnots()
  → GetIPDFPixmapMemoryView()
  → argbToImageData()
  → ctx.putImageData()
1
2
3
4
5
6
7

GetIPDFPixmapMemoryView() 取得 WASM 侧的像素内存视图;argbToImageData() 把 PDFCore 的 ARGB 像素整理成浏览器 ImageData 所需的格式。ARGB 表示透明度、红、绿、蓝通道;浏览器 ImageData 的通道顺序是 RGBA,不能把两者当成同一种排列直接使用。

页面画出来,只证明渲染成功;正文是否被修改,要看 PDFDocument 对应的文档内容是否已更新。在 Fabric 层画一段文字并不会自动改掉底层 PDF 的原文。

PDFHandler 封装还给出了更具体的初始化入口:loadPDFCore() 加载 /collaboration/PDFCore.js 胶水文件,在 Module.postRun 阶段取得运行时,再调用 CreateFactory()。胶水文件是 JavaScript 与 WASM 的连接层,负责加载模块、准备内存与绑定接口。

script 加载成功不等于 WASM 已经初始化成功。加载状态应在运行时就绪后才完成;多个页面同时请求时共用一个加载 Promise,失败后允许明确重试,避免重复插入脚本或提前开放编辑按钮。

打开文档的封装链路为 CreateDocument() → Uint8Array(buffer) → CreateBufferStream() → IPDFDocumentOpen()。只有打开结果成功,才读取 GetPages() 和 Count();错误密码、损坏文件不能继续当作空文档渲染。文档使用期间还要按 SDK 约定维持字节流与 PDFCore 对象的生命周期。

# 加密 PDF 的打开与权限检查

PDF 常见的密码保护分为两类,同一份文件可以同时设置:

  • 打开密码(User Password):控制能否查看文档。设置后,用户需要提供有效密码才能打开并读取内容。
  • 权限密码(Owner Password,也常叫编辑密码):用于更改或解除编辑、复制、打印等权限限制。它保护的不只是编辑功能,因此这里统一称为权限密码。仅设置权限密码的文件可以直接查看,但遵循 PDF 权限设置的阅读器或编辑器会限制相应操作。

例如,一份 PDF 要求输入密码才能阅读,同时禁止修改和打印:输入打开密码后可以查看;如果需要解除这些限制,还要通过权限密码验证。前端应区分用户当前是要打开文件,还是要取得修改权限。

对于设置了打开密码的 PDF,前端负责收集用户输入的密码,交给 PDFCore 校验;底层完成解密读取后,才能继续解析和显示页面。项目通过 select(file, password) 将密码传给 IPDFDocumentOpen(doc, stream, password),解密处理由 PDFCore 承担,前端负责组织调用与反馈结果。

  • 打开文件:需要密码时提示用户输入,校验成功后再读取页面;密码错误时提示重新输入。文件损坏或格式不受支持等错误应分别处理,不能都当作密码错误。
  • 检查操作权限:打开成功后,应根据底层返回的权限判断能否编辑、添加注释或执行其他操作;需要解除限制时,再进入权限密码验证流程。IPDFDocSecurityCheckPassword(doc.GetSecurity(), password, true) 用于密码检查,具体检查哪类密码、各参数含义及权限判定方式需以 PDFCore 接口定义为准。
  • 保存安全设置:带密码打开是在当前会话中读取文档;若要导出一个不再需要密码的文件,还需要有权修改文档安全设置,并按 SDK 的保存规则处理。现有打开和密码检查调用不足以确认项目已实现移除密码保护的导出功能。

# 保存为什么不是 Canvas 截图

PDF 文档的导出统一通过 PDFCore 底层接口完成:先将正文、图片、注释或译文的修改应用到文档对象,再由 PDFCore 生成页面内容、保存为 PDF 字节流,前端取得 Blob 后触发浏览器下载。

页面中的编辑与注释修改
  → applyContentAndGenerateAll()
  → GenerateContent():生成各页修改后的内容
  → Save(stream):把文档序列化为 PDF 字节流
  → getFileBlob():取得 PDF Blob
  → downloadFile(blob):创建下载地址并触发下载
1
2
3
4
5
6

翻译导出入口是 saveForAITranslate(),按上述顺序执行。若只截取 Canvas,原来的文字结构、注释信息和可编辑内容可能丢失,因此不能拿截图代替 PDF 文档保存。下载使用的 Blob URL 在使用完后也应释放。

# 高清渲染与坐标转换

# 高清渲染怎样用在 HiPDF 中

底层 PDF 页面和上层 Fabric 画布都要适配高分屏。底层保持 CSS 显示大小不变,内部像素宽高乘 DPR,并让 PDFCore 直接生成对应尺寸的像素;Fabric 层由 enableRetinaScaling 管理设备像素比,不能再重复套一层手动 DPR 缩放。

DPR 解决像素够不够,业务缩放解决页面显示多大。页面放大后,底层 PDF 要按新的渲染比例重画,上层对象也要使用相同视口变换。只放大已经画好的位图,文字会变糊。

原理、可口述答案与可运行示例见 Canvas 高清渲染。

HiPDF 的 renderPage(page, canvas, { viewScale }) 区分三个量:size 是基础页面尺寸,即按项目约定换算后,页面在 100% 缩放时的 CSS 显示宽高;viewScale 是用户缩放比例,例如 150% 对应 1.5;dpr 是设备像素比。基础尺寸以 CSS 像素为单位,用作缩放的计算基准,不能直接代入未经换算的 PDF 原始单位数值。

  • 显示尺寸 = 基础尺寸 × viewScale

  • 渲染像素尺寸 = 显示尺寸 × dpr

两条公式分别用于计算宽和高:显示尺寸对应画布的 CSS width、height,渲染像素尺寸对应画布内部的 canvas.width、canvas.height。例如,基础尺寸为 600 × 800,用户放大到 150%、DPR 为 2 时,CSS 显示尺寸为 900 × 1200,内部像素尺寸为 1800 × 2400。

公式算出的是画布需要的像素数量,高清显示还需要 PDFCore 按目标像素尺寸重新渲染页面。只增大画布再拉伸旧位图,仍会模糊;PDF 中原本低清的图片,也不会因为增加画布像素而产生更多细节。

实际渲染时,把计算出的内部像素宽高交给 renderPixmap(),PDFCore 生成相同尺寸的 ImageData,再调用浏览器 Canvas 2D 的原生方法 ctx.putImageData(imageData, 0, 0),将像素数据绘制到画布上。putImageData 不受 Canvas 变换矩阵影响,因此这里需要 PDFCore 直接生成足够分辨率的像素数据。普通 fillText()、路径绘制则可以通过绘图变换适配 DPR。

# PDF 坐标与画布坐标怎样对应

坐标 原点与单位 用途
画布显示坐标 通常左上角为原点,向下为正,使用 CSS 像素 Fabric 对象位置、DOM 输入框、鼠标交互
PDF 默认页面坐标 通常左下角为原点,向上为正,默认 1 单位约为 1/72 英寸 PDF 文档中的文本、图片和注释位置
归一化页面坐标 把页面上的位置转换成 0~1 的比例 在线注释的旋转还原和跨显示尺寸转换

按 CSS 单位换算,1 英寸 = 96 CSS 像素,因此 PDF 默认的 1 单位 = 1/72 英寸 ≈ 1.333 CSS 像素。实际显示尺寸还需结合阅读器的缩放约定计算。

归一化页面坐标是什么

归一化就是用位置占页面宽高的比例表示坐标:横向比例 = x ÷ 页面宽度,纵向比例 = y ÷ 页面高度。

例如,以页面左上角为原点,600 × 800 页面上的点 (150, 200),归一化后是 (0.25, 0.25),表示距左边、上边各占 25%。页面显示为 1200 × 1600 时,分别乘以新的宽高,就还原为 (300, 400),仍对应页面上的同一位置。

归一化用于跨尺寸换算;页面旋转时,还需配合旋转变换。

本手册记录的在线注释转换流程中,矩形、椭圆和画笔路径会先归一化,再逆旋转并还原到未旋转页面坐标,详见 页面旋转后,怎样保证批注位置正确?。这里的比例是转换过程中的中间值,最终保存什么坐标取决于数据格式。

CoordinateConverter 根据 GetDisplayMatrix() 建立页面变换,并提供 mapPointFromPdf(pdfX, pdfY) 和 mapPointToPdf(screenX, screenY)。画图时从 PDF 转到画布,点击时反向转换;缩放和旋转必须走同一套矩阵。

读取原生 PDF 注释时,也通过页面矩阵将 PDF 坐标转换为画布显示坐标,再定位批注。

在未旋转、单位已换算的简单情况下,可以用 y = pageHeight - y 理解上下原点的区别;但真实页面还可能有裁切、缩放和旋转,不能只用这一个公式处理所有文档。文本输入位置由 PDFCore 的光标接口定位,不是根据屏幕像素猜一个字符下标。

# 页面旋转后,怎样保证批注位置正确?

项目以页面未旋转时的坐标作为统一基准。用户在旋转后的页面上添加或修改批注时,先根据当前页面尺寸和旋转角度,把位置换算回这个基准再保存;显示时,再根据当前页面的旋转角度和缩放比例计算位置。这样不同用户即使以不同角度查看,批注也始终对应同一处内容。

不同图形使用同一套坐标变换规则,但需要转换的数据不同:

对象 保存前怎样处理
矩形、椭圆 将位置和尺寸归一化,撤销页面旋转,再换算到未旋转页面的宽高范围内
画笔路径 将每个路径点转换到页面坐标,逐点归一化并撤销旋转,再重建路径及包围它的矩形范围,避免只移动外框而笔画没有跟着变换
线段、箭头 转换两个端点,再计算位置和包围范围;箭头还要计入头部延伸的部分

例如,在左上角为原点、向右和向下为正的归一化坐标中,页面顺时针旋转 90° 后:

  • 新 x = 1 − 原 y
  • 新 y = 原 x

点 (0.2, 0.3) 因此变成 (1 − 0.3, 0.2),即 (0.7, 0.2)。原来距上边 30%,旋转后就变成距右边 30%,也就是距左边 70%;原来距左边 20%,则变成距上边 20%。保存时用 原 x = 新 y、原 y = 1 − 新 x,就能还原为 (0.2, 0.3)。

文字定位还要统一对象的参考点:如果一处用中心点、一处用左上角,需要先换算,否则保存后仍可能偏移。

相关代码见 核心文件与阅读入口 中的旋转还原条目。

怎样保证 PDF 页面和批注高清且不偏移?

建议回答:我把它分成高清渲染和坐标一致两部分。高清渲染方面,先根据用户缩放比例算出页面的 CSS 显示宽高,再乘 DPR,让 PDFCore 按这个像素尺寸渲染;上层批注开启 Fabric 的 enableRetinaScaling,由它按 DPR 增大画布内部像素尺寸并缩放绘图坐标,保持 CSS 显示大小不变,业务代码无需再手动乘一次 DPR。

坐标一致方面,底层页面和批注层使用一致的页面尺寸、缩放比例和旋转角度,鼠标位置也通过坐标转换定位到文档。保存在线批注时,统一换算到未旋转页面的坐标;显示时再按当前视图还原,这样缩放或旋转后,批注仍对应原来的内容。

PDF 页面放大后模糊,怎么处理?

建议回答:我会先检查是否只把旧画面拉大了,以及 PDFCore 输出的像素尺寸是否匹配当前显示尺寸和 DPR。放大后需要同步调整画布内部尺寸,并让 PDFCore 重新渲染。例如页面显示宽度是 900 CSS 像素、DPR 为 2,就需要输出 1800 像素宽的页面图像。

项目使用 Canvas 原生方法 putImageData(),将 PDFCore 渲染得到的像素数据绘制到画布上。这个方法不受 Canvas 坐标缩放影响,所以仅调用 scale 无法解决像素不足。如果模糊的只是 PDF 中原本低清的图片,重新渲染也无法补出缺失的细节。

# 注释工具与数据转换

# 11 类注释工具分别怎样实现

FabricCanvas 封装画布,把工具切换、绘制方法和对象事件收在同一处。初始化配置包括透明背景、isDrawingMode: true、selection: false 与 enableRetinaScaling: true;是否允许框选、是否进入自由绘制,随当前工具切换。

每页有独立的 FabricCanvas 实例,visibleFabricCanvasMap: Map<number, FabricCanvas> 管理可见页面;DynamicScroller 只为可视区域创建画布,离开区域后释放显示资源。对象数据仍保留,返回页面时重新还原。

工具 Fabric 对象或扩展 需要保存的数据与处理重点
矩形 Rect 位置、宽高、角度、填充、描边;把 scaleX/scaleY 折算进实际宽高
椭圆、圆 Ellipse 位置、rx/ry 半径、角度与样式;圆是 rx = ry 的情况
线段 Line 两个端点、位置、描边与线宽;不能只保存宽高
箭头 自定义 Arrow 在线段上增加 arrowSize/arrowAngle;端点变化时更新箭头头部
画笔 自定义 Pencil / 自由绘制 Brush 路径、颜色、角度、线宽;限制路径在画布边界内
文字 IText 文字、字体、字号、颜色、粗斜体,以及 textAlign/lineHeight/underline 等排版属性;处理字号与缩放的关系
高亮 自定义 TextDecoration 文字选区的多个边界和颜色;用 multiply 合成模式尽量保留原文字可见性
下划线 自定义 UnderLineStrikeOut 文字选区的边界和颜色;自定义渲染线的位置
删除线 自定义 UnderLineStrikeOut 同样依赖文字选区,但线画在文字中部
签名、图片 Image 位置、尺寸、角度及图片资源;本地上传后建立对象
便签、评论入口 自定义便签对象 图标位置、颜色与评论关联;双击等交互打开评论

fabricCore/type.ts 中的扩展承担特定绘制任务:Arrow 扩展 Line 并计算箭头点;Pencil 扩展 PencilBrush,在 createPath() 中记录工具类型,用 _clipPoint() 限制边界;TextDecoration 组合文字边界;UnderLineStrikeOut 通过自定义 _render() 绘制下划线或删除线。

对象统一带两个业务字段:obj.id = front_id 把画布对象关联到注释数据,obj.kind 表示工具类型。这样可以按 ID 找到对应对象,也可以按类型选择保存与还原逻辑。

画布封装提供 drawRect()、drawEllipse()、drawLine()、drawArrow()、drawFreeDraw()、drawInk()、drawText()、drawTextDecoration()、drawComment()、drawImg() 等入口。工具类型与方法数量不必一一对应,例如高亮、下划线和删除线可以共用文字装饰入口。

# 工具配置怎样驱动画布

Store 保存什么
toolData.ts 当前工具与活动对象
usePencilDataStore 笔刷粗细(范围为 1~20)与颜色
useTextDataStore 字体、字号、颜色、粗体与斜体
useToolShapeDataStore 形状边框色、填充色与线宽
useToolTextDecoDataStore 高亮、下划线与删除线颜色

工具栏 annotationTools/index.vue 包含 TextDecoDropdownMenu、PencilDropdownMenu、TextDropdownMenu、ShapeDropdownMenu,另有编辑当前对象的 ShapeFloatingPanel 和右键/选择菜单 SelectPopup。

用户点击工具后,toolDataStore.setSelectTools(type) 更新状态;Vue 的 watch 监听变化,再调用 FabricCanvas.setDrawingTool(type)。工具栏只决定用户要做什么,具体对象怎样画、怎样保存由画布与转换模块负责。

# 对象怎样变成可保存数据

Fabric 对象包含交互状态、内部缓存和变换信息,不应原样当作后端保存的注释数据。项目使用 buildAnnotAp() 抽取规范化的注释外观,再用 renderAnnotAp() 还原。

Fabric 对象
  → buildFunctionMap[kind]
  → 注释外观数据 AnnotationAppearance
  → API 保存 / WebSocket 传递
  → renderFunctionMap[type]
  → FabricCanvas.draw*()
  → object.id = front_id
1
2
3
4
5
6
7
类型 保存函数 还原要点
高亮、下划线、删除线 buildTextDecoration() 按多段文字边界重建装饰对象
画笔 buildPencil() 恢复路径和描边,而不是只恢复包围框
文字 buildText() 恢复文本、字体、字号与样式
矩形、椭圆 buildRectAndEllipse() 把对象缩放折算为实际尺寸或半径
线段、箭头 buildLineAndArrow() 恢复端点;箭头额外执行 updateArrow()
签名、图片 buildSign() 恢复资源、尺寸与位置
便签、评论 buildComment() 恢复图标并关联评论数据

新增工具时,要同时增加保存与还原的映射,不能只实现绘图按钮。还原流程按类型选择构造方法;页面当前有旋转时先转换位置,再加到画布。

注释层为什么选择 Fabric,而不是直接用原生 Canvas?

建议回答:主要是为了减少注释交互的开发成本。用户画完矩形、箭头或文字后,还需要选中、拖动、缩放和修改样式。直接用原生 Canvas,需要自己记录每个图形的状态、判断鼠标点中了哪个图形,并实现拖动和缩放控制。Fabric 把图形作为对象管理,提供了这些基础交互,我们可以在此基础上扩展注释工具,重点处理注释数据的保存与还原。

在线注释怎样保存,重新打开文档后怎样还原?

建议回答:保存时,把注释的类型、位置、路径和样式等信息整理成结构化数据,通过接口保存到后端,并用唯一标识关联画布对象。重新打开文档时,读取这些数据,按注释类型重新创建对应的 Fabric 对象,恢复到页面上。例如,画笔需要恢复完整路径,文字需要恢复内容、字体和字号。

为保证页面旋转后注释仍能对齐,保存时统一换算到未旋转页面的坐标,显示时再按当前页面角度转换回来。这里保存的是在线注释数据;用户导出 PDF 时,再通过 PDFCore 将注释保存进 PDF 文件。

# 原生注释互转与导出

# 在线注释和原生 PDF 注释怎样互转

打开已有 PDF 时,PDFCore 可能读到文件自带的注释。restoreWasmAttrsToFabric() 把这些注释转换到 Fabric:处理页面坐标、颜色和外观,再通过 buildAnnotAp() 进入在线注释数据链路。

导出则反向执行:convertFabricAttrsToWasm() 把 Fabric 对象转成 PDFCore 接受的注释属性,再通过 PDFCore 接口将注释添加到文档并保存为文件。颜色也需要转换:PDF 侧常用 0~1 的颜色分量,CSS 常用 0~255 的 RGB 与透明度。

在线数据库中的注释数据、画布上的对象、PDF 文件里的注释是三种表示,不是保存其中一种就自动拥有另外两种。互转层集中处理差异,避免每个工具各写一套坐标与颜色转换。

# 注释的写入、读取与绘制怎样对应

convertFabricAttrsToWasm() 先按类型转换几何数据,再附加 angle、stroke、strokeWidth、opacity 等公共属性。内部名称中,Square 是矩形,Circle 是圆/椭圆,Ink 是画笔,Writer 是文字注释,Stamp 是图章。

注释类型 写入外观 读取与坐标还原 前端重建
Square / Circle convertSquare/convertCircle → setRegionAP getRegionAP → restoreSquareCircle drawRect/drawEllipse
Line / Arrow convertLine/convertArrow → setLineAP/setArrowAP getLineAP/getArrowAP → restoreLine/restoreArrow drawLine/drawArrow,处理端点
Ink convertInk → setInkAP getInkAP → restoreInk drawInk,处理完整路径
Writer convertWriter → setWriterAP getWriterAP → restoreWriter drawText;还原时 isFocus: false,不自动进入输入
Stamp setStampAP getStampAP → restoreStamp 这份映射中的绘制分支为空,不能据此认定已经完成图章还原
高亮、下划线、删除线 类型专用文字装饰逻辑 getTextDecorationAP → restoreTextDecoration drawTextDecoration,保留文字选区边界

setAnnotKindAPMap、getAnnotKindAPMap 和 drawAnnotObjMap 分别负责写外观、读外观、创建画布对象。枚举里有某种注释,不代表读、写、交互三个方向都已支持。签名图片的 drawImg() 也不能直接证明原生 Stamp 分支完整。

addAnnot() 的关键动作是:newPageAnnot() 创建注释 → PDFRect 确定区域 → annot.GetAP() 取得外观 → docResources.AddForm(rect) 建立承载外观的 Form → form.GetGraphics() 设置填充、描边、线宽和透明度 → 按类型生成外观。这里的 Form 是 PDF 图形资源,不是网页表单;SetAlpha(opacity, opacity) 分别影响填充与描边。

绘制与还原是两条相反的流程:

新建:选择工具 → Fabric 对象与预览 → 移动/缩放/旋转
      → 边界限制 → convertFabricAttrsToWasm
      → addAnnot / setAnnotKindAPMap → 生成内容并保存

还原:打开 PDF → getPageAnnotsList → 识别 AnnotKindEnum
      → getAnnotKindAPMap → restoreWasmAttrsToFabric
      → drawAnnotObjMap → 绑定控制点、事件与评论关联
1
2
3
4
5
6
7

onObjectBoundLimited() / largeObjectBoundLimited() 用于限制对象不要越出页面。旋转对象要检查变换后的包围框;不能只判断未旋转的宽高。

为什么还原注释时会移除文档对象中的旧注释?

建议回答:为了避免同一条注释在 PDF 底图和 Fabric 交互层各显示一份,否则拖动上层对象后,底图上仍会留着原来的注释。项目先完整提取位置、路径和样式并创建 Fabric 对象,再通过 deletePageAnnot() 移除 PDFCore 文档对象中的对应注释,导出前通过 PDFCore 接口重新添加。暂不支持完整转换的注释继续保留在文档中,避免内容丢失。

# 对象旋转与翻转的矩阵变换

页面旋转与对象旋转是两件事:前者决定整页怎样显示,后者决定一个批注本身的角度。对象还可能有 flipX/flipY 镜像翻转,需要连同位置、路径与外观一起转换。

按 PDF 的六参数约定 [a, b, c, d, e, f],点变换为 x' = a×x + c×y + e、y' = b×x + d×y + f。前四项共同描述缩放、旋转与错切,e/f 是平移;PDFCore 通过 form.SetMatrix() 应用组合变换。先旋转再翻转,与先翻转再旋转不一定相同,绕中心旋转也不同于绕原点旋转。

宽为 w、高为 h 的矩形旋转 θ 后,轴对齐包围框为 w' = |w×cosθ| + |h×sinθ|、h' = |w×sinθ| + |h×cosθ|。例如 100×40 的矩形转 90° 后包围框变成 40×100;位置还要结合旋转中心修正,不能只交换宽高。

# 原生注释的读取、创建与刷新

原生注释保存在 PDF 文档里,除了文字等内容,还需要描述它在页面上怎样显示。这部分显示信息称为 AP(Appearance,注释外观)。下面介绍读取、创建与刷新三个环节,其中创建与编辑以文字注释为例,其他类型的注释使用各自的绘制接口。

  1. 读取已有注释:从页面取得注释内容和外观,创建用于显示的对象。对应调用为 AnnotateManager.getAnnotateList() → page.GetPageAnnots() → AnnotateFactory。
  2. 创建文字注释:在文档中新增注释,并生成文字外观。AnnotateManager.addAnnotateText() 调用 PDFCore 的 PageAnnotWriter 相关能力,再用 IPDFGraphics.DrawVariableText() 绘制文字。文字编辑时,PDFFactory.CreateVariableTextEditor() 与 textEditor.LoadAnnot() 用于读取段落、字体和字号等属性,输入内容最终通过 PDFCore 的 InsertText() 写入文档。
  3. 刷新显示:修改内容或样式后,先清理旧外观缓存,再生成新内容并刷新页面。对应接口是 appearance.ClearCachedAP() 和 form.GenerateContent(),避免数据已更新,屏幕却仍显示旧样式。

# 正文、图片编辑与撤销重做

# 如何修改 PDF 原有文字

PDF 原文不是一个普通 DOM 文本框。前端直接调用 PDFCore 分析页面布局、识别文本块,再通过 PDFCore 接口定位光标并修改文字,整个原文编辑流程不使用 Fabric。

Block.getBlockInfoList()
  → page.CreatePageLayout()
  → layout.Initialize(RecognizeUsingParagraphMode)
  → layout.GetBlocks()
  → 得到文本块、位置、段落与图像信息
1
2
3
4
5

选定 PDFCore 识别出的文本块后,通过 Vue 的 EditInput 收集输入;输入位置通过 block.GetCursorPosition(point) 确定,block.InsertText(cursorPosition, wideStr) 修改 PDF 文本,再由 refreshCurrentPageNotNew() 重画页面。

选区高亮通过 IPDFTextSelector 获取范围,GetTextBounds() 返回所选文字的四角边界,也就是 Quads。把这些边界转到显示坐标后,再绘制高亮,不能仅按鼠标拖出的一个矩形推测选中了哪些字。

# 编辑状态机与渲染工厂

状态机把交互分为 INITIAL、MOVING 和 INPUT:未编辑、选中后可移动/缩放、正在输入。BaseUIBlock 统一管理切换,避免拖动事件与文字输入同时生效。这里的 MOVING 也代表选中后可调整对象的状态,不是只有鼠标正在移动才进入。

RenderClassFactory.create(pageNum, type, elObj) 按类型创建处理对象,公共生命周期放在 RenderBase 中,具体行为由子类实现。

RenderBase
├─ RenderEditBlock:正文文本块编辑
├─ RenderEditObjectImage:原有图片编辑
├─ RenderAnnotateObj:注释公共行为
│  ├─ RenderAnnotateTextbox:文字注释
│  └─ RenderAnnotateTextBlock:文本块注释
├─ RenderText / RenderTextWASM:文本渲染
├─ RenderSignImage(WASM):签名图片
├─ RenderSticker:贴纸
└─ Shape(WASM):圆、线、矩形等形状
1
2
3
4
5
6
7
8
9
10

PDFCore 注释类型中,HighlightSeg 表示已经提交的文字高亮,HighlightTemp 表示选择过程中的临时高亮,Textbox 表示文字注释。它们与在线工具清单是不同层面的分类,不是只有三种工具。

# 原有图片怎样移动、缩放与删除

前端通过 PDFCore 识别原有图片:Block.getContentObjList() 遍历 page.GetContentObjects(),找到 IPDFContentObjectKindImage 类型。用户调整图片时,通过 PDFCore 接口修改对应图片对象,不把它转换成 Fabric 注释对象。

  • 移动通过平移矩阵表达,例如 PDFMatrix(1, 0, 0, 1, deltaX, deltaY)。
  • 缩放通过比例矩阵表达,例如 PDFMatrix(scaleX, 0, 0, scaleY, 0, 0)。
  • layout.UpdateContentObject(matrix) 修改 PDF 图片对象;删除调用 layout.DeleteContentObject(index),并记录可撤销操作。

矩阵要作用在正确坐标系中。不能直接把屏幕拖动的像素增量当成 PDF 单位;缩放中心也要与交互层保持一致。

# 撤销重做为什么用操作而不是截图

OperationManager 维护 undoStack 和 redoStack,撤销栈上限为 100 条。每个 OperationTask 保存业务上下文、WASM 可逆操作和执行回调。

字段 用途
data blockId、pageNum、cursorInfo、sPos、type 等定位与恢复信息
op PDFCore 提供的 ReversibleOperation,保存可逆的文档修改
execute() 执行操作入口
executeUndoCb() 撤销后恢复光标、对象与界面状态
executeRedoCb() 重做后同步对应状态

撤销时调用 op.Revert(null) 回退文档修改,再恢复光标、刷新页面,把操作移到重做栈;重做时调用 op.Commit(null) 重新应用,再更新状态并移回撤销栈。操作类型包括 editBlockText、editBlockImage、addAnnotateText 和 deleteAnnotate。

恢复的是文档内容和业务状态,不只是画布像素。一次拖动结束应记录为一个操作;撤销后出现新编辑时,旧重做分支应清空。在线协作中的撤销还要识别自己的修改,不能直接回退整个共享文档,把其他人的操作一起抹掉。

拖动过程中只更新显示,不要每次鼠标移动都保存完整快照。执行撤销、重做时应暂停记录新历史,避免恢复对象时触发的变更事件,又被当成一次新的编辑。

# 编辑状态具体保存什么

editPDF 字段 含义
curPDFDoc 当前文档实例
curEditTab EDIT 正文编辑模式或 ANNOTATE 注释模式
curActiveElement 当前活动的交互对象,需结合正文编辑或注释模式理解
curEditContentCursorInfo 屏幕位置、PDF 坐标与 PDFCore 光标等输入信息
editPageList HiPage 页面实例集合
opManager 撤销重做管理器
curCursorPositionFont 当前输入位置的字体属性

# 字体为什么按需加载

按需加载是为了减少首次打开文档时不必要的下载和等待,以及未使用字体的内存占用。字体文件尤其是包含大量字形的中文字体,体积可能较大,而当前文档和编辑操作只会用到部分字体,没必要一开始就加载全部字体资源。

项目将字体包放在项目内,作为静态资源随应用部署,浏览器在需要时才加载对应字体文件。FontManager 在 PDFCore 请求未加载字体时,通过 OnlineFontMatch 回调查找匹配字体,fetchFont(url) 从对应的静态资源地址加载文件,再由 PDFFactory.CreateFontFromFile() 创建 PDFCore 字体资源,并缓存到 fontMatchCache。

例如,原文使用的字体已嵌入 PDF,但只包含原文用到的字形;用户输入新文字时,仍可能需要补充字体资源。按需加载把这部分开销推迟到真正需要时,缓存则减少后续重复加载。代价是首次使用缺失字体时可能需要等待,因此应处理加载中和失败提示,并检查缺字、替换字体后字宽和换行位置的变化。

直接修改 PDF 原有文字,与添加文字批注有什么区别?

建议回答:修改原文是改变 PDF 已有的文字内容,添加文字批注则是额外增加一个注释对象。HiPDF 的原文编辑直接调用 PDFCore 识别文本块、定位光标并修改文字,再刷新页面,不经过 Fabric。Fabric 只用于注释层,负责批注的绘制、选中和拖动;导出时再把批注转换成 PDF 注释写入文件。

撤销重做怎样做到不只是恢复画面?

建议回答:我会记录 PDFCore 的可逆操作和恢复所需的页码、对象、光标等上下文,而不是保存截图。OperationManager 用撤销栈和重做栈管理操作,撤销调用 Revert,重做调用 Commit,再恢复输入状态和重画。这样 PDF 内容与界面一起变化。拖动结束才记一次操作,不随每次鼠标移动保存快照;撤销、重做时暂停记录新历史,避免把恢复动作记成新编辑。撤销后新编辑要清掉旧重做分支;多人协作也不能回退整份共享文档。

# 第三部分 AI 翻译回填

这一模块解决怎样把整份 PDF 翻译成目标语言,并把译文写回文档、生成可下载的 PDF。它不只有回填:还包括段落提取、请求分组、翻译会话、结果关联、排版调整、预览与导出;回填是其中最复杂的一步。

# 翻译流程与业务检查

# 翻译的完整链路

上传 PDF → 检测扫描件,按需调用后端 OCR 接口转换
  → PDFCore 解析和布局分析 → 按段落提取原文
  → 分批调用翻译 API → 按原顺序关联译文
  → 保留格式并替换原段落 → 检查排版、调整字号
  → 重画预览 → 生成并导出 PDF
1
2
3
4
5

这里最难的不是得到一句译文,而是知道译文属于文档中的哪个段落,并把它写回原位置。翻译 API 负责语言转换,PDFCore 负责修改文档,两者通过段落记录关联。

# 用户场景、前置检查与双栏阅读

学生与研究者用它阅读外语论文;职场与跨国团队用它翻译合同、项目资料和内部文件;翻译服务提供者需要交付多语言文档。共同需求是原文与译文能对照阅读,译文能直接导出,不必逐段复制再手工排版。

翻译入口还有业务前置条件,不是上传后就直接调用翻译 API:

  1. 检查登录,未登录先引导登录。
  2. 上传并检查加密状态;翻译流程对加密文档提示重新上传可处理版本。编辑器可以带密码打开文件,与翻译入口的限制是不同规则,详见 加密 PDF 的打开与权限检查。
  3. 检测扫描件;需要 OCR 时,前端调用后端 OCR 接口,将文件转换成具有可提取文字的 PDF,再交给 PDFCore 进行段落识别。
  4. WASM 渲染并提取文本块、段落,检查翻译次数或额度;不足时引导购买,足够时发起翻译。
  5. 接收译文、写回并调整版式,展示原文/译文,最后导出。

OCR 是 Optical Character Recognition,即光学字符识别,用于把图片中的文字识别出来。项目中,IsScannedDocument() 负责检测扫描文档,OCR 转换通过后端接口完成;前端取得转换后的 PDF,再由 PDFCore 提取文本块和段落,进入翻译与回填流程。

界面支持多种主流语言,默认用浏览器语言作为目标语言,用户可以修改;这个默认值不等于自动检测原文语言。原文与译文左右并列、同步滚动,便于逐页核对。保留原格式是产品目标,长译文、缺字和复杂排版仍要检查。

同步滚动建议按 “页码 + 页内相对位置” 对齐,并区分主动滚动与程序设置,防止两栏互相触发循环;项目是否采用这一具体实现仍待核对。

扫描 PDF 怎样翻译,加密文件与额度不足怎样处理?

建议回答:先做业务检查,再进入翻译。登录后上传,翻译入口对加密文档提示重新上传可处理版本;检测到扫描件后,前端调用后端 OCR 接口,转换成能提取文字的 PDF,再交给 PDFCore 提取段落,复用后续翻译和回填链路。发翻译请求前还检查额度,不足时引导购买。OCR 的错字和阅读顺序错误可能影响译文,需要在对照预览中核对。

# 段落提取与分批翻译

# 段落提取为什么比按整页字符串更合适

Block.getBlockInfoList() 先分析文本块,getParagraphs() 再取得块中的段落。RecognizeUsingParagraphMode 用于布局与段落识别;扫描文档先通过后端 OCR 接口完成转换,再进入这一步。

Block 是布局块,一个块可能含多个段落;Paragraph 是这条翻译链路的最小处理单元。每段同时保留原文、PDFCore 段落对象、边界、光标和行数,用于将返回的译文回填到文档对应位置。

下面是字段说明用的 TypeScript 结构摘要;PDFCore 对象以 unknown 表示,不冒充 SDK 的真实类型定义。

type PdfPoint = { x: number; y: number }; // PDF 坐标,不是屏幕像素。

interface ParagraphRecord {
  id: string;           // UUID,关联当前任务中的段落。
  pageNum: number;      // 所属页面,遵守内部页码约定。
  text: string;          // 从 PDFCore 提取的原文。
  textTranslate: string; // 翻译 API 返回的译文,提取时为空。
  paragraph: unknown;    // 保留的 WASM 段落对象,供后续回填。
  rect: {
    ltPoint: PdfPoint;   // 原段落左上、左下、右下、右上四角。
    lbPoint: PdfPoint;
    rbPoint: PdfPoint;
    rtPoint: PdfPoint;
    cursorPosStart: unknown; // 段落起始光标,来自 PDFCore。
    cursorPosEnd: unknown;   // 段落结束光标,来自 PDFCore。
  };
  lineCount: number;     // 原段落行数,用于布局分析。
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

如果 PDF 没有可提取的文字层,例如纯扫描图片,项目会先调用后端 OCR 接口转换文件,随后复用同一条文字提取与回填链路。识别错误、阅读顺序错误仍可能传递到译文,因此还要检查提取结果的准确性。

具体提取过程是:GetBlocks() 获取块 → GetContentBound() 取得四角 → mapPointFromPdf() 得到显示位置 → GetParagraphs() 遍历段落 → GetBound() 与 GetCursorPosition() 取得段首、段尾 → GetString(start, end) 读取 PDFCore 返回的字符串 → ConvertWideString() 转成 JavaScript 文本 → GetLines().GetSize() 记录原行数。

块记录还保留 UUID、块类型、paragraphList、当前 scale,以及 ow/oh/ol/ot(除去显示缩放后的宽、高、左、上位置)。页面显示位置用于定位文本块的选中框;段落的 PDF 坐标与光标用于将译文写回文档中的对应位置。

# 批次、任务分组与接口字段

后端翻译接口要求每次最多提交 90 段,并按三个子任务组织数据。AITranslator.vue 的 startProcess() 按有文字的页面逐批处理段落,通过 convertTo2DArrayBySubArrayCount(arr, 3) 将当前批次分成三个子数组,满批时为 30 + 30 + 30,再以二维数组提交给后端。

三个子数组放在同一个 HTTP 请求中。前端按页面、批次等待 batchTranslate() 返回,再继续下一批;不足 90 段时只提交实际段落,分组也相应缩短。

接口为 POST /v2/aicloud/translate/batch,字段摘要如下。字符串数组中的每项是一个段落,字段名称沿用项目接口。

interface TranslationRequest {
  texts: string[][];    // 三个子任务的原文数组;满批共 90 段。
  to_language: string;  // 目标语言,例如 Spanish。
  task_id: string;      // 首次取值遵守接口初始化约定,取得响应标识后续批复用。
  fn: 0 | 1;           // 1 表示最后一批,通知后端结束并释放会话资源。
  type: 'translate';   // 当前任务类型。
}

interface TranslationResponse {
  task_id: string;      // 同一文档翻译会话的标识。
  result: string[][];   // 对应各子数组的译文。
  use_token: number;    // 响应返回的 Token 用量。
}
1
2
3
4
5
6
7
8
9
10
11
12
13

译文经 result.flat() 按批次追加到 totalResult,再按段落顺序回写 textTranslate。这依赖后端保证子数组与段内顺序;回填前应检查结果数量匹配,不能少了一项后把后续译文全部错配。并行任务应按分组位置收集结果,不能按完成先后拼接。

task_id 用于关联同一文档的多次请求;仅有这个字段并不能证明后端怎样使用上下文。正常结束以 fn = 1 通知释放资源,异常中断仍需要服务端超时清理或取消机制,不能假设最后一批一定发得出去。isFirstError 标记用于避免失败后反复弹出相同错误提示,不等于错误已经恢复。

后端翻译接口有单次处理上限,前端如何完成整篇文档的翻译?

建议回答:前端按接口要求分批提交。项目的后端接口每次最多接收 90 段,并要求分成三个子任务,前端将这三组数据放在同一个请求中,按页、按批等待结果后继续下一批,直到整篇翻译完成。每批译文按原分组和段落顺序整理,再关联到对应段落;回填前应检查结果数量,避免漏项导致后续译文错位。

# 译文回填、排版与导出

# 译文回填的六个步骤

Block.ts 中的 setAllTranslateBackText() 对每个段落执行以下过程。

步骤 做什么 为什么必须这样
1. 保存原位置 getParagraphRect()、GetBound()、GetCursorPosition() 取得边界与起止光标 之后删除原文,仍要知道在哪个段落和区域插入
2. 保存原格式 建立光标范围,再用 GetAttributes(ranges) 读取字体、字号、颜色与粗斜体 删除后原有格式信息可能丢失
3. 删除原文 deleteRangeTextByCursorForTranslate() 移到段首、段尾后执行 paragraph.Remove(ranges) 避免译文叠加在原文上
4. 恢复边界 用已保存四角建立 PDFQuadrangle,调用 paragraph.SetBound(quad) 空段落边界可能改变或消失,先恢复原来的写入区域
5. 带格式插入 insertTextForTranslate() 定位段首,调用 InsertWithAttributes() 继承可用的字体、字号、颜色与样式
6. 检查排版 formatParagraph() 比较新旧高度,必要时减小字号 译文长度改变后,避免直接挤出原区域

删除前取得的格式范围与插入后使用的有效范围,应遵守 PDFCore 接口语义;不能把已经失效的光标对象或范围当成永远可复用的普通字符串下标。

# 译文变长后,怎样自动调整字号

译文比原文长时,沿用原字号可能让文字超出原来的区域。项目最终采用按文本块高度缩放字号的算法,让译文尽量放回原区域,在项目当时使用的大多数文档中,这种方案的适配效果更好。

这个名称描述的是按文字占用的高度调整字号。实际代码中的 formatParagraph() 接收一个段落记录,逐个段落测量和调整,并非一次缩放整个 Block 中的所有段落。

调整过程是:

  1. 从段落记录中取得原高度,再读取回填后的段落高度;新高度未超过原高度时直接返回。
  2. 如果新高度大于 “原高度 + 5”,且字号大于 0.5,就将字号减小 0.1,再读取排版后的高度,并更新下一次修改使用的光标范围。
  3. 循环结束后,只要字号小于或等于 0.5,就恢复原字号;这一步不会再次判断高度是否合适。

下面是 Block.ts 中按高度缩放的核心代码:PARAGRAPH 是项目的段落记录类型,this.PDFCore 是已加载的 PDFCore,this.getParagraphRect() 是项目封装的段落边界与光标读取方法。

formatParagraph(paragraphItem: PARAGRAPH) {
  const { paragraph, rect: oldRect } = paragraphItem;
  // 使用保存的原边界,以及译文回填后的当前边界计算高度。
  const oldHeight = oldRect.ltPoint.y - oldRect.lbPoint.y;
  const rect = this.getParagraphRect(paragraph);
  const { curCursorPosStart, curCursorPosEnd } = rect;
  let newHeight = rect.ltPoint.y - rect.lbPoint.y;
  if (newHeight <= oldHeight) return;

  let ranges = new this.PDFCore.IPDFCursorPositionCollectionImpl();
  ranges.Insert(ranges.GetEnd(), curCursorPosStart);
  ranges.Insert(ranges.GetEnd(), curCursorPosEnd);
  const originalFontSize = paragraph.GetAttributes(ranges).mFontSize;
  let fontSize = originalFontSize;

  // 允许超出原高度 5 个坐标单位;每轮减少字号后重新测量。
  while (newHeight - 5 > oldHeight && fontSize > 0.5) {
    fontSize -= 0.1;
    paragraph.SetFontSize(fontSize, ranges);
    const currentRect = this.getParagraphRect(paragraph);
    newHeight = currentRect.ltPoint.y - currentRect.lbPoint.y;

    // 排版后重新取得光标范围,供下一轮调整使用。
    ranges = new this.PDFCore.IPDFCursorPositionCollectionImpl();
    ranges.Insert(ranges.GetEnd(), currentRect.curCursorPosStart);
    ranges.Insert(ranges.GetEnd(), currentRect.curCursorPosEnd);
  }

  // 触及字号下限就回退,使用进入循环前取得的起止光标。
  if (fontSize <= 0.5) {
    ranges = new this.PDFCore.IPDFCursorPositionCollectionImpl();
    ranges.Insert(ranges.GetEnd(), curCursorPosStart);
    ranges.Insert(ranges.GetEnd(), curCursorPosEnd);
    paragraph.SetFontSize(originalFontSize, ranges);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

这里的 5 是允许高度超出的容差,单位跟随段落边界坐标;字号的 0.1 是调整步长,0.5 是循环保护阈值,不能作为可读字号标准。由于先判断再减小,字号最后也可能略低于 0.5,再进入恢复分支。

另一种按行数缩放的算法,是在译文行数超过原行数时逐步减小字号。但行数相同,文字占用的高度仍可能因字号、行距而不同。按高度判断更直接对应 “能否放进原区域” 这个目标,因此项目最终选择了高度算法。

下面是按行数缩放的版本,与上面的高度版本是同一方法的两种实现。lineCount 是翻译前保存的原段落行数,其余依赖与上面的代码相同。

formatParagraph(paragraphItem: PARAGRAPH) {
  const { paragraph, lineCount } = paragraphItem;
  // 回填后的行数未超过原行数,就无需缩小字号。
  if (paragraph.GetLines().GetSize() <= lineCount) return;

  const rect = this.getParagraphRect(paragraph);
  const { curCursorPosStart, curCursorPosEnd } = rect;
  let ranges = new this.PDFCore.IPDFCursorPositionCollectionImpl();
  ranges.Insert(ranges.GetEnd(), curCursorPosStart);
  ranges.Insert(ranges.GetEnd(), curCursorPosEnd);
  const attributes = paragraph.GetAttributes(ranges);
  let fontSize = attributes.mFontSize;

  // 每轮读取当前行数;不超过原行数或触及字号下限时停止。
  while (paragraph.GetLines().GetSize() > lineCount && fontSize > 0.5) {
    fontSize -= 0.1;
    paragraph.SetFontSize(fontSize, ranges);

    // 字号改变后重新取得段落边界和光标范围,供下一轮使用。
    const currentRect = this.getParagraphRect(paragraph);
    ranges = new this.PDFCore.IPDFCursorPositionCollectionImpl();
    ranges.Insert(ranges.GetEnd(), currentRect.curCursorPosStart);
    ranges.Insert(ranges.GetEnd(), currentRect.curCursorPosEnd);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

这里的目标是让译文行数不超过原行数,不要求严格相等。行数版本达到字号下限后直接结束,保留缩小后的字号,没有高度版本中恢复原字号的分支;此时行数仍可能超出。

这两种实现尚未包含最小可读字号、独立的迭代次数限制或溢出提示。后续可补充这些保护,并在自动调整无法容纳译文时提示用户人工处理。

# 从译文到下载文件

调用方 动作 结果
AITranslator.vue startProcess() 获取段落并调用 batchTranslate() 段落记录关联译文
Block.ts setAllTranslateBackText() 通过 PDFCore 将译文回填到文档的原段落
页面管理 refreshCurrentPageNotNew() 重新渲染译文 PDF 页面,用于与原文对照阅读
PDFDocument.ts saveForAITranslate() 生成各页内容、保存字节流并下载 PDF

译文通过 PDFCore 回填到文档中。页面上与原文并排显示、可同步滚动对照的译文 PDF,以及最终导出的 PDF,都基于修改后的文档对象。

# 为什么回填选 WASM,而不是直接铺 HTML 译文

关注点 Canvas + PDFCore HTML 文本 + 定位
文档修改与导出 通过 PDFCore 修改原文档的段落,复用文件保存能力 DOM 上显示译文,不代表原 PDF 已改变;导出要另做
样式与注释 能复用客户端的文档与注释模型 要重建文本位置、样式和注释关联
语言与排版 受 PDFCore 字体、字形处理和布局能力约束 可利用浏览器成熟的多语言显示与排版能力,配置目标语言字体和文字方向
性能成本 文本块识别、PDFCore 布局、像素渲染和复制 大量 DOM、布局计算与浏览器绘制
开发取舍 复用已有 PDFCore,底层接口对接较重 阅读展示容易起步,但要尽量保持原 PDF 的布局与样式,并导出为 PDF,仍需额外开发

HiPDF 的核心需求不只是显示译文,还要尽量保持原 PDF 的布局与样式,并生成可下载的 PDF,因此复用 PDFCore 更贴合现有架构。HTML 方案可以利用浏览器成熟的多语言显示与排版能力,适合译文阅读展示;翻译接口支持目标语言、配置好字体和文字方向后,通常就能正常显示。保持原 PDF 的布局与样式仍需额外处理,导出时还要确认所用 PDF 工具的字体与排版能力。

# 第四部分 协同注释与评论

这一模块解决多人怎样围绕同一份 PDF 同步批注和评论,并减少同时修改造成的冲突。它复用注释模块的绘制与还原能力,额外负责在线存储、同一文档内的协作消息同步、对象锁与成员状态。

# 注释与评论的数据同步

# 创建、更新、删除与重新打开

创建入口 createAnnotationData() 在用户完成绘制后处理 Fabric 对象:先通过 permissionDenied() 与 checkAnnotationLimits() 做权限与数量检查,再把旋转位置还原,调用 buildAnnotAp(),生成注释数据,写入 annotateDataStore.addAnnotation();随后调用 createAnnotation() 保存,并发送 annotationNotify(Create) 通知正在协作同一份文档的其他用户。

项目的批注数量限制为每页 100 个、每文件 800 个。这是项目策略,不是 Fabric 的技术上限。客户端检查用于及时反馈,服务端也应执行同样限制,避免绕过前端接口直接写入。

每个注释使用前端生成的 32 位 UUID 字符串 front_id,画布对象通过 object.id = front_id 关联。这样创建请求返回前就能识别对象,不必等待服务端分配 ID。

场景 处理过程
本人创建 将绘制的对象转换成注释数据,加入本地状态,再调用接口保存并通知同一文档的其他协作用户
本人修改 根据修改后的内容、样式和位置更新注释数据,调用接口保存,再通知其他协作用户
本人删除 按注释 ID 移除本地数据和画布对象,调用接口删除服务端对应的注释数据,再通知其他协作用户
收到他人创建 根据消息中的注释数据,在本地还原对应的 Fabric 对象并显示到页面
收到他人修改 按注释 ID 找到并移除旧对象,再根据新数据重建,让页面显示最新内容
收到他人删除 按消息中的注释 ID 移除本地对应的注释
重新打开文档 从服务端获取已保存的注释,按类型还原 Fabric 对象,并通过注释 ID 关联数据与画布对象

拖动或调整注释时会频繁触发更新,因此通过约 1 秒的节流控制同步频率,减少请求和消息数量。

# 注释数据中的字段是什么意思

以下是类型摘要,用于说明注释数据的结构;unknown 表示随工具变化的外观对象,不是省略的可运行实现。

interface AnnotationRecord {
  front_id: string; // 注释唯一标识,与 Fabric 对象的 id 对应。
  page: number;     // 所属页;页码从 0 还是 1 开始要遵守项目约定。
  content: {
    annot_data: {
      type: string;        // rect、ellipse、pencil、text、arrow、line、comment 等。
      ws_id: number;       // 创建者的协作连接标识,不是持久用户身份凭证。
      annot_ap: unknown;   // 规范化的位置、路径、文字与样式,结构随类型变化。
      create_date: number; // 创建时间。
      modify_date: number; // 修改时间。
    };
    reply_list: Array<{
      ws_id: number;
      email: string;
      content: string;
      create_date: number;
      modify_date: number;
      comment_id: string;        // 评论唯一标识。
      parent_comment_id: string; // 空串表示主评论,否则指向被回复的评论。
    }>;
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

front_id 回答 “这是哪个注释”,comment_id 回答 “这是哪条评论”,ws_id 回答 “消息来自哪个协作连接”。三者不能相互替代,授权也不能仅凭客户端传来的 ws_id。

# 评论怎样与注释关联

评论依附于注释,放在 reply_list 中。第一条 parent_comment_id 为空的记录作为主评论,后续回复指向对应主评论。commentData Store 把接口数据整理成界面需要的页、数量、评论和回复列表,并显示用户名、时间、内容和编辑状态。

评论操作会先更新本地列表,再调用接口保存,并通过 WebSocket 将变化同步给正在协作同一份文档的其他用户。

用户操作 本地处理与保存 其他协作用户如何同步
添加回复 生成回复数据并加入本地列表,调用接口保存 收到消息后,在对应主评论下显示新回复
修改主评论 更新本地显示的主评论内容,调用接口保存修改 收到消息后,找到对应主评论并更新内容
修改回复 更新本地显示的回复内容,调用接口保存修改 收到消息后,找到对应回复并更新内容
删除评论或回复 从本地列表移除对应内容,调用接口删除服务端数据 收到消息后,从列表中移除对应评论或回复

前端根据当前用户与评论的归属、文档权限决定是否显示编辑和删除入口;文档所有者还可拥有删除他人评论的权限。

项目为什么用 HTTP 接口保存数据,用 WebSocket 同步变化?

建议回答:这套分工能兼顾开发成本和协作实时性,主要有三个好处:

  • 保存结果容易对应:HTTP 的每个请求都有对应的响应,前端可以据此处理某次保存的成功或失败。直接用 WebSocket 承载保存请求,则需要自行约定或借助框架实现请求编号、结果匹配和等待超时等机制。例如,Socket.IO (opens new window) 提供消息回复确认和超时设置;ASP.NET Core SignalR (opens new window) 支持客户端调用服务端方法,并通过 Promise 获取返回结果或错误。
  • 协作变化及时推送:其他用户何时修改无法预知,WebSocket 可以在变化发生时主动推送,省去客户端定时查询,也减少等待下一次查询的延迟。
  • 复用现有业务接口:项目已有注释和评论的增删改查接口,保留这些接口、用 WebSocket 同步变化,可以减少把保存流程迁入 WebSocket 所需的协议设计和改造工作。

也可以通过 WebSocket 提交修改,再由后端保存。例如实时协作后端 Hocuspocus (opens new window) 就支持这种方式;采用现成框架时,可复用其同步与持久化扩展能力。Socket.IO 和 SignalR 主要封装通信能力,具体的数据保存仍由业务后端实现。

# 协作连接与消息分发

# 从连接到离开房间

协作房间(Room)是按文档划分的消息分组:正在协作同一份 PDF 的用户加入同一房间,批注、评论和成员状态等协作消息就在这个分组内同步,不会广播给其他文档的用户。

useCollaboration.initializeCollaboration(shareId) 先检查登录。未登录时保存 pendingShareId,登录完成后再继续。socketManager.createConnection(shareId) 获取连接与房间信息、创建 Socket,连接成功后调用 joinRoom(roomId)。

组件注册 status/message/error 回调处理连接状态和消息;离开时通过 gracefulDisconnect() 发送退出通知,并清理监听与连接。心跳用于发现失联,不能把浏览器仍显示在线当成连接一定有效。

# 消息如何分发到具体动作

项目在消息协议中定义了一组业务消息码,通过 op 字段携带,用来区分加入协作、离开协作、心跳、房间消息和定向消息。收到消息后,先根据 op 选择对应的处理逻辑;如果是房间内的协作消息,再根据 action 区分创建、修改、删除注释或评论等具体操作。

例如,一条修改注释的消息,先通过 op = 14 识别为房间消息,再通过 action = "update" 进入注释更新逻辑,按注释 ID 找到对应对象并更新页面。

业务消息码与对应代码类如下:

业务消息码(op) 含义 对应代码类
9 DISCONNECT,离开或断开协作 DisconnectHandler
10 JOIN_ROOM,加入当前文档的协作房间 JoinRoomHandler
12 / 13 PING / PONG,心跳 HeartbeatHandler
14 ROOM_MESSAGE,房间内广播 RoomMessageHandler
15 P2P_MESSAGE,定向消息 P2PMessageHandler

下面是客户端收到注释或评论协作消息后的处理流程。箭头表示消息从 WebSocket 接收入口,经过类型分发,最终进入具体业务逻辑的顺序;其中的名称对应项目中的回调、方法或监听列表。

WebSocket.onmessage
  → Socket 实例回调
  → socketManager.messageListeners
  → messageRouter.route(message):按业务消息码 op 分发
  → 房间消息中的 annotationHandler
  → annotationNotifyHandler(message)
  → 判断是否是自己发出的消息:是则跳过,避免重复处理
  → 其他消息按 action 执行对应的注释或评论处理逻辑
1
2
3
4
5
6
7
8

自己添加或修改注释时,当前页面已经更新;如果服务端又把这条消息广播回来,就不需要再执行一次。判断时比较消息里的 senderWsId 与当前页面的连接 ID,因此同一账号在其他页面发来的修改仍会正常同步。

最后一步根据 action 选择具体处理逻辑,常见动作如下:

action 处理逻辑与对应代码
create annotationNotifyCreateHandler,还原并创建对象
update annotationNotifyUpdateHandler,替换旧对象并重画
delete annotationNotifyDeleteHandler,按 front_id 删除
lock annotationNotifyLockHandler,记录锁并关闭远端交互
UnLock annotationNotifyUnLockHandler,解除锁并恢复交互
syncLock annotationNotifySyncLockHandler,新成员加入时同步当前锁
createReply commentNotifyCreateReplyHandler,添加回复
updateMainComment commentNotifyUpdateMainCountHandler,更新主评论
updateReply commentNotifyUpdateReplyHandler,更新回复
deleteReply commentNotifyDeleteReplyHandler,删除回复

动作字符串和代码名称沿用内部实现。大小写有区别,例如 UnLock,不能随意改成另一个字符串后还期待旧协议能识别。

annotationNotifyActionMap 和 commentNotifyActionMap 分别维护注释与评论的动作映射,将各个动作对应到独立的处理逻辑,避免所有逻辑挤进一个巨大的消息回调。

发送消息包括 op、auth_token、room_id 与 content。其中 content 承载 action、注释或评论数据、发送连接、页码与时间信息;以下为协议字段摘要。

interface CollaborationContent {
  action: string; // 注释、评论与锁动作,具体值见上表。
  data: {
    annotation?: unknown; // 注释完整数据,或包含 front_id 的定位数据。
    senderWsId: number;   // 用于识别自己发送的回显。
    pageNum: number;
    lockTimeout?: number; // 锁超时时间,默认 120000 ms。
    lockTimestamp?: number;
    commentData?: unknown; // 评论或回复数据。
  };
  timestamp: number;
}
1
2
3
4
5
6
7
8
9
10
11
12

如何设计协作消息去重,避免同一个操作执行两次?

建议回答:我会采用唯一消息 ID 加已处理记录的方案。发送方每产生一个新操作,就生成一个 messageId;如果只是重试或补发,沿用原来的 ID。接收方先跳过当前页面自己发出的消息,再按顺序处理其他消息:ID 已处理过就跳过,没处理过就执行,成功后记下这个 ID。按顺序处理也能避免前一条还没完成,重复消息就再次执行。

例如,另一位用户添加了一条回复,消息 ID 是 A。当前页面第一次收到 A 时显示回复并记录 A;业务重试再次发来 A 时就跳过。对方再添加一条新回复,会生成新的消息 ID,因此仍能正常显示。连接 ID 用来判断谁发的,消息 ID 用来判断是不是同一次操作。

已处理记录可以放在当前文档的内存缓存中,保留时间应覆盖约定的重试和补发窗口,再清理过期记录,避免内存一直增长。这解决的是缓存有效期内的重复消息;页面刷新后需要重新获取服务端数据,不能仅靠内存记录保证跨刷新去重。

# 编辑锁与并发冲突

# 乐观锁、悲观锁与前端编辑锁

并发控制策略 怎样工作
乐观锁 允许同时编辑,保存时检查版本;版本已变化就拒绝覆盖,重新读取后处理冲突
悲观锁 先申请锁,确认获得独占权限后才能修改;其他人等待锁释放或放弃操作

例如,乐观锁可以让 A、B 都读取版本 7:A 保存成功后版本变为 8,B 再携带版本 7 保存就被拒绝。版本检查与写入必须作为一个不可分割的操作执行,否则两人仍可能同时通过检查。

如果在多人编辑中采用严格的悲观锁,可以由服务端统一分配对象锁:

  1. A、B 同时申请编辑同一条注释,在收到成功结果前都不能开始编辑。
  2. 服务端把 “检查是否空闲、登记锁持有者” 作为一个不可分割的操作执行,保证只有一个申请成功。假设 A 拿到锁,B 就需要等待或放弃。
  3. A 收到成功结果后开始编辑,服务端接收修改时也检查其是否仍持有有效锁。广播负责更新其他人的界面,即使 B 尚未收到广播,也无法取得锁或提交修改。

HiPDF 采用悲观式编辑策略的前端对象锁:选中注释时立即在本地记录占用状态,再通过 WebSocket 通知其他客户端禁用该对象。它通过提前占用来减少编辑冲突;但本地加锁与其他客户端收到通知之间有时间差,如果两人同时选中,各自都可能认为自己已占用该对象。要保证同时只有一个人取得编辑权,还需要统一的锁申请与确认机制。

# 编辑锁怎样阻止同一个对象被同时拖动

为减少多人同时拖动、缩放同一注释造成的冲突,项目采用对象级编辑锁:用户 A 选中一个注释时,广播 lock,并在本地记录拥有者;用户 B 收到后,把这个对象设为 selectable: false、evented: false,取消已有选中状态,再显示锁边框。

A 选中对象
  → annotationNotify(Lock, { id, senderWsId, lockTimeout: 120000 })
  → annotateDataStore.setAnnotationLock()
  → 房间广播

B 收到 lock
  → 记录该对象由 A 编辑
  → 等待页面画布和目标对象准备好
  → 关闭对象交互、取消选中、显示拥有者边框
1
2
3
4
5
6
7
8
9

长文档采用虚拟滚动,未进入渲染范围的页面可能还没有画布和对应的 Fabric 注释对象。项目已经通过 Store 记录锁状态,waitForFabricCanvas() 的有限重试用于等待正在创建的画布;如果页面根本没有开始渲染,短暂重试无法等到它出现。

更完整的处理方式是独立保存锁状态,在注释对象创建时应用。锁记录放在当前文档的前端 Store 内存中,按注释 ID 建立 Map,记录占用者的连接 ID、加锁时间和所在页。锁记录跟随当前文档保存,页面画布卸载时仍然保留。

可以按以下流程补全:

  1. 收到加锁消息,先更新 Store;对象已经渲染时,立即禁用交互并显示锁边框。
  2. 页面进入渲染范围,创建注释对象时查询 Store,根据当前有效的锁状态设置交互和边框。
  3. 页面移出渲染范围,只销毁画布和注释对象,保留锁记录;收到解锁或成员离开消息时清除对应记录,并恢复已渲染对象的交互。
  4. 刷新页面后内存记录会丢失,重新加入协作时需要同步当前锁状态;切换文档时清理旧文档的记录。

例如,A 正在编辑第 20 页,B 还停在第 1 页。B 收到消息后只需记下锁状态,等滚动到第 20 页、创建对应注释时再应用锁,无需一直轮询等待该页出现。项目是否已经在对象重新创建时读取并应用锁状态,目前尚未确认;上面的补全流程用于说明这一场景的处理方案。

本地占用状态在选中时立即记录,其他客户端的交互限制在收到通知后生效。保存接口仍需独立校验用户的编辑权限。

# 锁边框、解锁、失联与新成员同步

场景 如何处理
显示编辑者 用编辑者对应的颜色绘制锁边框。边框只作界面提示,不响应操作,也不应进入导出的 PDF
注释变化或删除 注释移动、缩放时更新锁边框,删除时一并移除边框
取消选中 清除本地锁并广播解锁消息,其他客户端恢复该注释的交互、移除锁边框
收到离开通知 清除该成员持有的锁,恢复对应注释的交互
断连且未收到通知 需要超时清理等兜底,避免注释一直被占用;项目的自动清理逻辑尚未确认
新成员加入 同步注释内容和当前锁状态,让新成员看到哪些注释正在被谁编辑

项目的锁超时参数默认是 2 分钟,但自动到期释放和持续编辑时的续期逻辑尚未确认。若补全超时机制,需要同时考虑这两种情况,避免残留锁,也避免用户仍在编辑时锁提前失效。

# 编辑锁与乐观更新怎样配合

乐观更新是先在本地显示操作结果,不等保存请求成功再更新界面。例如提交评论后,立即把它显示在列表中,让用户马上看到反馈。乐观锁负责保存时检查并发冲突,乐观更新关注界面的响应速度。

项目可以同时使用前端编辑锁和乐观更新:用户选中批注后广播编辑状态,其他客户端收到后限制操作;用户修改批注或评论时先更新本地 Store,再调用 API 保存。前者减少操作冲突,后者让界面及时响应。

保存失败时,需要根据失败原因恢复界面,或标记未保存并提供重试入口。

项目怎样控制多人同时编辑同一批注?

建议回答:我们采用前端对象级编辑锁,减少多人同时拖动、缩放同一批注造成的冲突。选中时在本地记录占用状态,通过 WebSocket 广播;其他客户端收到后禁用该对象,并用边框显示编辑者。取消选中或收到成员离开通知时解除限制,新成员加入时同步当前锁状态。锁记录保存在 Store 中,与画布对象分开管理。

追问:项目用的是乐观锁还是悲观锁?

建议回答:采用悲观式编辑策略,通过提前占用来限制他人操作。当前依靠本地加锁和广播,两人同时选中时仍可能冲突。若要保证只有一人成功占用,需要统一申请和确认锁,并在提交修改时校验有效持有者。

追问:为什么选择对象级编辑锁,而没有引入 CRDT?

建议回答:当前主要需求是多人操作不同批注,并限制同一批注被同时编辑,对象级锁更贴合这种交互,实现成本也较低。CRDT 也能用于图形协作,但需要设计位置、尺寸、删除等操作的冲突处理规则。若以后需要多人同时修改同一对象或离线合并,再评估引入 CRDT 的收益。

CRDT 是什么

CRDT 是 Conflict-free Replicated Data Type,中文叫 “无冲突复制数据类型”。它是一类支持多端同时修改、按规则自动合并的数据结构;各端收到相同的更新后,最终得到一致的结果,常用于协同文档、在线白板和离线编辑。

例如,A 在文档开头插入一句话,B 同时在末尾删除几个字,两人的修改同步后,各端按相同规则合并,最终看到一致的文档。编辑锁通过限制同时操作来减少冲突,CRDT 则允许并发修改,再按规则合并。

“无冲突” 指合并结果能够保持一致,并不代表自动理解用户意图。两人把同一个矩形拖到不同位置时,仍需选择合适的数据结构和规则来决定最终位置。常见的相关库有 Yjs (opens new window) 和 Automerge (opens new window)。

# 第五部分 加载与渲染性能

# 哪些优化与项目场景直接相关

问题 优化方式 收益与代价
WASM 加载慢 首次下载使用 Brotli 压缩,后续访问读取带版本的 IndexedDB 缓存 减少传输量和重复下载;仍需编译、初始化,并维护缓存版本与失败回退
长文档画布过多 用 DynamicScroller 只挂载可见页及少量缓冲页,离开渲染范围后释放画布 减少首屏渲染和内存开销;页面重建时需还原注释,业务数据与显示资源分开管理
拖动请求过密 更新请求按约 1000 ms 节流,拖动结束时提交最终状态 减少接口调用;其他用户看到的中间状态更新频率降低,需确保最终状态提交
字体加载开销大 按需加载项目内的字体资源,并缓存已加载资源 减少未使用字体的下载和内存占用;首次使用缺失字体时仍需等待
批注过多导致卡顿 限制单页最多 100 个、单文件最多 800 个批注 控制对象数量带来的渲染与交互开销;限制了可添加数量,具体阈值需结合实际文档验证
高分屏与放大模糊 PDFCore 按显示尺寸、缩放与 DPR 输出像素,Fabric 使用 Retina 适配 改善清晰度;更高像素量会增加渲染耗时和内存,需避免重复叠加 DPR

WASM 在项目里做什么?用了它就一定快吗?

建议回答:WASM 承载 PDFCore 引擎,负责 PDF 解析、布局分析、渲染、光标定位、修改和保存,让浏览器能够调用已有引擎能力。但它不是自动多线程,也不保证所有操作都快。大页渲染、像素复制和翻译回填仍可能卡主线程,所以先用虚拟滚动、按需加载和分批处理减少工作量,再根据耗时决定是否迁移到 Worker。

如果后续需要使用 Worker,可以先迁移像素格式转换、批量坐标计算等独立的耗时任务,Vue 界面和 Fabric 交互仍留在主线程。主线程通过 postMessage 发送任务,Worker 返回结果后再更新页面,并用任务 ID 与文档、视图版本过滤过期结果。若要迁移 PDFCore,应先确认它支持 Worker 环境,再将引擎和文档实例一起放入 Worker 管理。目标是减少计算对界面交互的阻塞。

# WASM 首次压缩、再次访问走版本缓存

PDFCore 的原始 WASM 约 14 MB,首次访问要经历下载、编译与初始化。项目用了两层优化:Brotli 减少首次传输量,IndexedDB 减少再次访问的重复下载,它们解决的不是同一个阶段。

历史记录 数值
Brotli 压缩文件 14.3 MB → 3.8 MB,传输体积减少约 73%
未使用本地缓存的加载样本 平均 4011 ms
使用 IndexedDB 后的加载样本 平均 927 ms,相比未缓存样本耗时减少约 77%

加载耗时来自当时测试环境的少量样本,仅衡量 WASM 加载,不代表整个 PDF 页面的首屏耗时。

压缩可使用 Google Brotli 工具 (opens new window)。下面是 Windows 命令示例:工具和待压缩文件放在 C:\brotli 后执行,不是项目安装脚本。

# 使用已下载的 Brotli 可执行文件,把 WASM 生成压缩副本。
cd C:\brotli
.\brotli.exe -o PDFCore.wasm.br PDFCore.wasm
1
2
3

部署时,压缩副本要返回正确的 HTTP 响应头:

Content-Type: application/wasm
Content-Encoding: br
1
2

浏览器根据 Content-Encoding 解压,恢复原始 WASM 字节,文件大小也恢复为压缩前的大小;流式实例化要求正确的 WASM MIME 类型。只把文件改名为 .wasm.br,不会自动变成可执行的 WASM。现代主流浏览器支持 Brotli HTTP 解码,线上一般通过 HTTPS 使用;不支持或资源配置异常时需要有未压缩文件的受控回退。

项目调整 PDFCore.js 的核心加载链路如下:

createWasm()
  → WASMCache.getWasm():读取与当前 PDFCore 版本匹配的 ArrayBuffer
  ├─ 命中:作为 wasmBinary 编译、实例化
  └─ 未命中:请求 PDFCore.wasm.br
       → 浏览器按响应头解压
       → response.clone(),准备缓存所用的独立响应
       → 原响应 instantiateStreaming,副本 arrayBuffer 读取字节
       → WASMCache.setWasm() 异步保存解码后的字节
       → 等待 PDFCore 运行时就绪
1
2
3
4
5
6
7
8
9

上面的 response.clone() 是为了让实例化和缓存各自读取响应内容:响应体只能读取一次,所以要在读取前创建副本。

  • 缓存内容:IndexedDB 保存浏览器解压后的 WASM 字节(ArrayBuffer)。命中缓存后省去下载,仍需编译、实例化和初始化 PDFCore。
  • 版本更新:缓存同时保存 PDFCore 文件版本,只有与当前版本匹配时才复用,否则重新下载并更新缓存。升级时应将配套的 PDFCore.js、WASM 文件和版本标识一起发布,避免新旧文件混用。
  • 失败处理:缓存读取失败时应转为网络加载,写入失败也应允许编辑器继续启动。若加载仍失败,应提示错误并提供重试入口,避免页面一直停在加载中。
  • 选型原因:IndexedDB 适合异步保存较大的二进制数据,方便应用主动读取缓存、检查文件版本;相应地,也需要维护版本失效和读写失败的处理逻辑。通用存储知识见 浏览器本地存储。

WASM 加载优化具体怎么做,收益如何衡量?

建议回答:我主要优化了首次下载和再次访问两个场景。首次通过 Brotli 将 WASM 的传输体积从 14.3 MB 减到 3.8 MB;下载后把解压后的文件数据存入 IndexedDB,再次访问时,版本一致就直接读取缓存,版本变化则重新下载。

收益主要看传输体积和 WASM 加载耗时。已有测试样本中,未使用缓存时平均约 4 秒,使用缓存后约 0.9 秒。衡量时应保持设备、网络和计时范围一致,分别测试缓存未命中与命中的情况;这里统计的是 WASM 加载耗时,缓存命中后仍需编译和初始化。

# 大文档如何只渲染当前需要的页面

数百页 PDF 如果一次性创建所有 Canvas 和文本层节点,会增加初始渲染时间和内存占用。项目通过虚拟列表保留完整页面数据,只为可见页及前后少量缓冲页渲染内容。

把每一页 PDF 看作一个列表项,虚拟滚动主要做三件事:

  1. 保留滚动空间:根据各页高度和页间距计算整份文档的总高度,由占位容器撑起滚动区域。屏幕外的页面暂时不绘制,滚动条仍能滚到整份文档的末尾。
  2. 计算显示范围:根据滚动距离、可视区域高度和各页的累计位置,找出当前可见页,再向前后扩展少量缓冲页,减少快速滚动时等待渲染的空白。
  3. 更新页面内容:将需要显示的页面放到对应的滚动位置;滚动后更新范围,复用或回收离开范围的显示节点。各页高度尚未确定时先估算,测量后再修正总高度和页面位置。

例如,一份 300 页的 PDF 当前显示第 50~52 页,可以渲染第 49~53 页作为可见页和缓冲页,其余页面只保留数据及高度信息。实际缓冲范围按页面大小、渲染速度和设备内存调整。

项目使用第三方库 vue-virtual-scroller (opens new window) 提供的 DynamicScroller 组件实现虚拟滚动,并通过 Vue 3 Composition API 组织页面渲染逻辑。它适合各页显示高度不一致的 PDF 列表,可以减少自行维护高度测量、节点复用和滚动位置计算的工作。

下面是库提供的组件、配置与方法:

组件或 API 作用
DynamicScroller 管理可视范围、列表总高度与滚动定位
DynamicScrollerItem 包裹单个页面内容,测量实际高度,供列表修正布局
min-item-size 在实际高度测出前提供最小尺寸估计
emit-update 开启列表范围变化通知,供业务层安排页面渲染
scrollToItem 按列表下标跳转;页码从 1 开始时,跳到第 n 页传入 n − 1

项目自己维护的 allPages 保存完整页面列表,viewPageRenderMap 记录页面是否已渲染。PDFCore 绘制、文本层创建和注释模式下的 Fabric 初始化,则由业务代码根据显示范围安排,优先处理可见页。

PDF 场景还需要处理三个问题:

  • 缩放后保持位置:缩放或旋转会改变页面高度,需要重新计算布局,并按当前阅读页和页内位置调整滚动距离,减少突然跳页。
  • 避免显示旧内容:快速跳页、缩放或切换文档时,应取消或忽略已过期的渲染任务。复用渲染缓存前,也要检查文档、页码、倍率、DPR、旋转和内容是否变化,再决定是否重画。
  • 回收后恢复编辑:页面退出渲染范围时,应清理旧画面、事件监听和不用的画布资源;注释数据、未保存修改及编辑锁保留在文档状态中。再次显示该页时重新渲染,并还原注释及锁状态。

评估效果时,应在相同文档、设备、网络和缓存条件下,对比首个可见页的渲染完成时间、滚动流畅度,以及长时间翻页后的内存峰值。

数百页 PDF 如何通过虚拟滚动优化性能?

建议回答:核心是保留整份文档的滚动高度,只渲染当前可见页和少量缓冲页。根据滚动位置计算要显示的页码范围,让画布和 DOM 数量主要由当前显示范围决定,减少大文档同时渲染的开销。项目使用 vue-virtual-scroller 的 DynamicScroller 处理列表布局和动态高度,业务代码按需调用 PDFCore 绘制页面,并在注释模式下创建 Fabric 层。

PDF 场景还需要处理缩放后的高度和阅读位置变化,忽略过期的异步渲染结果,并在回收画布时保留注释数据和编辑状态,方便页面再次显示时恢复。

# 第六部分 技术难点与解决方案

# 难点一:缩放与旋转后的坐标一致性

  • 现象:页面缩放或旋转后,点击位置与批注不对齐,保存后重新打开还可能出现位置偏移。
  • 原因:鼠标交互、画布显示和 PDF 文档使用的坐标原点、单位不同,页面旋转又会改变位置关系。DPR 用于提高画布内部像素密度,参与坐标换算时容易被重复计算。
  • 处理:先将鼠标位置换算为画布内的 CSS 像素坐标,再通过页面变换的逆变换定位到 PDF 坐标;绘制时使用正向变换。在线注释保存前还原到未旋转页面,显示时再按当前旋转角转换,让点击、绘制与保存使用一致的坐标规则。

具体过程见 PDF 坐标与画布坐标怎样对应 和 页面旋转后,怎样保证批注位置正确?。

# 难点二:译文回填时保留原版式

  • 现象:译文长度变化后,直接插入可能改变字体、颜色和段落位置,甚至超出原文本区域。
  • 原因:回填需要同时处理 PDF 段落的位置、格式和排版;删除原文后,原有边界与格式可能变化,而不同语言的字宽和换行也不同。
  • 处理:删除前保存原段落边界和格式,通过 PDFCore 恢复写入区域、带格式插入译文,再比较回填前后的段落高度。项目最终选择按文本块高度缩放字号:逐个段落减小字号并重新测量,使译文尽量放回原区域。在当时使用的大多数文档中,这种方案比按行数缩放的适配效果更好。

具体过程见 译文回填的六个步骤 和 译文变长后,怎样自动调整字号。

# 难点三:多人协作时的对象编辑冲突

  • 现象:两个人同时拖动或缩放同一注释,双方持续发送不同的位置和尺寸,收到对方消息后会互相覆盖。
  • 原因:位置和尺寸需要明确由谁修改;只把变化广播出去,各客户端仍可能同时操作同一个对象。
  • 处理:项目采用前端对象级编辑锁:选中时立即记录占用状态,通过 WebSocket 通知其他客户端禁用该对象并显示编辑者;取消选中或收到离开通知后解除占用。这样能减少同一对象的操作冲突,但广播到达前仍存在同时选中的窗口;若要保证同一时刻只有一人获得编辑权,需要服务端统一确认锁归属。

具体过程见 编辑锁与并发冲突。

# 难点四:长文档的按需渲染与状态保留

  • 现象:数百页 PDF 同时创建高清画布和注释对象,会占用大量内存;回收页面时,又需要保留用户已经做出的编辑。
  • 原因:画布、Fabric 实例属于当前页面的显示资源,注释数据和未保存修改则属于文档状态,两者的保留时间不同。
  • 处理:项目通过 DynamicScroller 管理显示范围,优先渲染可见页和少量缓冲页。回收时应清理不用的画布与监听,保留文档中的编辑数据;页面再次显示时据此重建。缩放或旋转后还需要更新高度,并忽略过期的异步渲染结果,避免旧画面覆盖当前页面。

具体过程见 大文档如何只渲染当前需要的页面。

# 难点五:注释交互与 PDF 导出的结果一致性

  • 现象:注释在页面上显示正常,导出后却可能重复,或丢失颜色、透明度及旋转效果。
  • 原因:Fabric 对象和 PDF 文件中的注释使用不同的数据格式。除了转换外观属性,还要控制原生注释与 Fabric 层的绘制,避免同一条注释显示两次。
  • 处理:将支持编辑的原生注释转换为在线注释数据、交给 Fabric 显示后,移除 PDF 文档对象中的对应注释;导出时统一转换注释的类型、位置与外观属性,通过 PDFCore 接口添加到文档并保存。不支持转换的类型保留原生表示,保存后重新打开可检查是否重复或丢失外观。

具体过程见 原生注释互转与导出。

# 第七部分 重点问题速查

问题与答案跳转 回答要点
项目介绍 PDF 编辑、AI 翻译与协同三条业务主线
前端职责与协作边界 前端交互、PDFCore 对接与服务端职责
注释层为什么选择 Fabric,而不是直接用原生 Canvas? 对象管理、基础交互与开发成本
为什么选择 PDFCore,怎样与前端配合 原文编辑需求、既有能力复用与前端分工
怎样保证 PDF 页面和批注高清且不偏移? DPR、业务缩放、统一坐标与旋转
直接修改 PDF 原有文字,与添加文字批注有什么区别? 修改文档正文与新增注释的区别
撤销重做怎样做到不只是恢复画面? 可逆操作、双栈与界面状态同步
译文回填的六个步骤 先保留位置和格式,再替换并检查布局
译文变长后,怎样自动调整字号 按段落高度调整字号、与行数算法的取舍
后端翻译接口有单次处理上限,前端如何完成整篇文档的翻译? 按接口约定分批提交,保持译文与段落对应
项目为什么用 HTTP 接口保存数据,用 WebSocket 同步变化? 数据保存与实时同步的分工,以及通过 WebSocket 提交修改的替代方案
项目怎样控制多人同时编辑同一批注? 前端对象级编辑锁、广播同步与同时选中的限制
WASM 加载优化具体怎么做,收益如何衡量? 首次压缩、版本缓存与历史测量范围
数百页 PDF 如何通过虚拟滚动优化性能? 占位高度、可视范围计算与页面资源管理

# 附录 项目资料与术语速查

# 核心文件与阅读入口

下面的名称是项目内部代码定位,不是本笔记网站中的文件路径。

模块 主要文件或对象 阅读时看什么
文档与页面 PDFDocument.ts、PageDrawer、HiPageWASMv2 打开文件、建立页面对象、重画与导出
布局与翻译 Block.ts、AITranslator.vue 段落提取、批次调用、译文回填
画布封装 fabricCore/index.ts、FabricCanvas 画布初始化、绘图方法、工具切换
自定义对象 fabricCore/type.ts Arrow、Pencil、TextDecoration、UnderLineStrikeOut
数据双向转换 fabricCore/onlineDataUtil.ts buildAnnotAp 与 renderAnnotAp
矩形旋转还原 mapPageRotatedObjectToOriginal() 矩形、椭圆的位置与宽高转换,处理 left/top/width/height
画笔旋转还原 transformPencilToOriginal() 逐点转换后重建路径、包围框和 pathOffset,修正 angle
线段旋转还原 computeOriginalFabricForLine() 转换端点、重算包围框,并处理箭头头部范围
页面旋转 pageRotationUserMap、rotateRelativePosition() 前者按页面 ID 记录累计旋转角度;后者围绕归一化页面中心 (0.5, 0.5) 转换位置
PDF 互转 fabricCore/convertUtils.ts、restoreUtils.ts Fabric 与 WASM 注释、颜色、坐标的转换
编辑状态 editPDF Store、OperationManager 输入光标、当前对象、撤销与重做
在线状态 annotateData.ts、commentData.ts 注释集合、评论列表、锁与本地更新
类型定义 annotate.ts、comment.ts、websocket.ts 注释、回复和消息字段的含义
协作连接 socketManager.ts、messageHandler.ts、useCollaboration.ts 建连、房间、消息分发和清理
注释与评论逻辑 annotation.ts、comment.ts、注释工具逻辑 annotate.ts 创建、还原、更新与删除
API apis/cloud/annotate.ts、comment.ts、socket.ts 持久化接口、评论接口、连接所需信息

# 技术术语速查

术语 一句话理解与详细介绍
PDFCore 团队提供的 PDF 处理引擎,以 WebAssembly 形式在浏览器中运行详见:六层架构与双层画布
WASM WebAssembly,浏览器可执行的编译产物格式;项目用它运行 PDFCore详见:WASM 在项目里做什么?用了它就一定快吗?
Fabric.js 在 Canvas 上管理可交互对象;本项目只用于注释层,不参与原文和原有图片的编辑详见:注释层为什么选择 Fabric,而不是直接用原生 Canvas?
DPR Device Pixel Ratio,设备像素比;决定相同显示尺寸需要多少渲染像素详见:高清渲染怎样用在 HiPDF 中
Block / Paragraph 布局块与段落;一个块可以包含多个段落,翻译按段落关联原文与译文详见:段落提取为什么比按整页字符串更合适
OCR Optical Character Recognition,光学字符识别;项目调用后端接口,将扫描 PDF 转换为可提取文字的 PDF详见:用户场景、前置检查与双栏阅读
AP Appearance,PDF 注释外观;决定注释在页面上怎样显示详见:原生注释的读取、创建与刷新
front_id / ws_id 分别标识注释对象和协作连接,连接标识不能替代授权身份详见:注释数据中的字段是什么意思
前端编辑锁 选中批注后记录并广播编辑状态,其他客户端收到后限制操作详见:编辑锁怎样阻止同一个对象被同时拖动
乐观锁 / 悲观锁 前者保存时检查版本冲突,后者确认获得独占权限后才允许修改详见:乐观锁、悲观锁与前端编辑锁
乐观更新 先更新本地界面,再请求保存;它改善操作反馈,本身不解决并发冲突详见:编辑锁与乐观更新怎样配合
Brotli / IndexedDB 分别减少首次下载的传输量、保存再次访问可复用的 WASM 文件数据详见:WASM 首次压缩、再次访问走版本缓存
虚拟滚动 保留完整页面数据,只挂载可见页与少量缓冲页的显示资源详见:大文档如何只渲染当前需要的页面

# 相关知识与官方资料

上次更新时间: 2026年10月14日 14:57:24