HiPDF 在线 PDF 编辑器面试手册

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

HiPDF 有三大并列功能:PDF 编辑与注释、AI 翻译回填、协同注释与评论。编辑模块解决文档修改,翻译模块完成从原文提取到译文 PDF 导出的完整任务,协同模块让多人共享批注与讨论。它们复用部分文档与画布能力,但各自有独立的业务流程和技术难点。

先读 项目介绍与共用架构,再分别复习三个功能模块。面试前重点练习 技术难点 和 高频面试题。

性能与导出专题:WASM 加载优化、大文档虚拟滚动、HTML 报告导出。

功能模块与阅读入口 完整业务流程 面试重点
一、PDF 编辑与注释 打开文档 → 编辑原文或图片、添加注释 → 撤销重做 → 保存 PDF 双层画布、高清与坐标转换、工具实现、内核写回
二、AI 翻译回填 提取段落 → 分批翻译 → 关联译文 → 带格式回填 → 调整排版、导出 PDF 段落定位、批次与顺序、格式保留、字号自适应
三、协同注释与评论 分享并加入房间 → 保存与同步批注、评论 → 编辑锁与成员状态同步 API 与 WebSocket 分工、消息协议、编辑冲突、断线恢复

内部接口与实现边界

PDFCore、HiPage、Block、RenderClassFactory 等名称沿用项目实现资料,是内部类或 SDK 接口,不是浏览器标准 API。本文按项目资料整理前端调用链;服务端锁裁决、失败回滚和重连补偿等可靠性要求单独说明,不把前端行为当成已经验证的服务端保证。

# 项目介绍与共用架构

# 项目介绍

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

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

# 技术栈在项目中的分工

技术或模块 在 HiPDF 中负责什么 不负责什么
Vue 工具栏、输入框、翻译面板、评论列表与用户交互 不直接解析 PDF 二进制
Pinia 当前文档、页面、编辑模式、活动对象、工具样式与评论状态 不是服务端持久化数据库
Canvas 显示 PDF 页面的像素与交互层 画出文字不等于修改 PDF 文件中的文字
Fabric.js (opens new window) 管理矩形、文字、路径等对象,提供选中、拖动、缩放和事件 不是 PDF 解析、排版或文件写回引擎
WebAssembly (opens new window) / PDFCore 解析文档、分析段落、计算光标与文本边界、渲染页面、修改并保存 PDF 不替代 Vue 的界面和在线协作服务
REST API 保存、查询、更新、删除在线批注和评论 不主动把修改推送到其他用户页面
WebSocket 加入文档房间,实时传递批注、评论、锁和成员状态 通知到达不等于数据已经持久化

WASM 是什么

WASM 是 WebAssembly 的简称,是浏览器可以执行的编译产物格式,常见文件后缀为 .wasm。本项目把 PDF 内核加载到浏览器,通过 JavaScript 调用它的接口。它可以复用已有 PDF 引擎,但不会自动把计算放到后台线程;是否使用 Worker 是另一件事。

# 六层架构与双层画布

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

每页在视觉上由两层组成:底层显示 PDF 页面,上层是透明的 Fabric 画布,显示可选中、可拖动的批注或编辑对象。两层必须共用页面尺寸、缩放和旋转信息,否则会出现看得见却点不准、批注随缩放漂移的问题。

三个模块共用 PDFDocument、页面管理和部分坐标转换能力,但使用方式不同:编辑模块处理用户主动修改;AI 翻译模块提取段落、调用翻译服务,再批量写回内核;协同模块通过 API 保存批注与评论,通过 WebSocket 同步其他用户。AI 翻译复用 PDF 内核的回填和保存能力,不意味着它只是编辑模块里的一个按钮。

# 为什么选择团队 PDF 内核,怎样与前端配合

原有分享页存在清晰度不足、加载慢、数百页或多图片文档滚动卡顿、移动端适配不足的问题。新编辑器还要修改 PDF 原有文字与图片,不能只解决浏览器预览。

选择 PE 底层团队提供的 PDFCore,主要是复用客户端已有的解析、编辑和保存能力,让网页与客户端的文档处理尽量一致。前端负责业务流程、对象交互和内核对接,不是重新实现整个 PDF 引擎。

PDF.js (opens new window) 适合解析和展示 PDF,也有表单、注释编辑与保存能力;但不能直接等同于任意修改已有 PDF 正文、图片并重新排版的完整编辑内核。这里的选型依据是编辑需求与跨端复用,不是说 PDF.js 只能阅读和高亮。

集成方式 具体做法 优势与代价
前端管理批注,导出时写入内核 Fabric 维护交互对象,导出前把批注列表转换成内核注释,再由 WASM 保存 工具交互开发方便;要维护两种对象表示,坐标、样式与外观必须双向对齐
内核更新文档,前端驱动并重画 前端调用内核完成编辑,再渲染内核结果 文档与预览更容易共用一套结果;光标、布局与编辑 API 对接更深入,开发成本较高

这两条路线在客户端方案中都被讨论过,主要同步的是批注列表,不是把整张 PDF 页面在前端与内核之间反复复制。HiPDF 的双层画布适合前端管理批注;原文编辑与翻译回填则必须真正修改内核文档。不能把所有操作都归为同一条链路。

# 核心文件与阅读入口

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

模块 主要文件或对象 阅读时看什么
文档与页面 PDFDocument.ts、PageDrawer、HiPageWASMv2 打开文件、建立页面对象、重画与导出
布局与翻译 Block.ts、AITranslator.vue 段落提取、批次调用、译文回填
画布封装 fabricCore/index.ts、FabricCanvas 画布初始化、绘图方法、工具切换
自定义对象 fabricCore/type.ts Arrow、Pencil、TextDecoration、UnderLineStrikeOut
数据双向转换 fabricCore/onlineDataUtil.ts buildAnnotAp 与 renderAnnotAp
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 持久化接口、评论接口、连接所需信息

# 一、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. PDF 内核把页面内容和已有注释渲染成像素,JavaScript 把结果放到 Canvas;Fabric 负责上面的交互对象。

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

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

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

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

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

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

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

# 保存为什么不是 Canvas 截图

保存的目标是输出修改后的 PDF 文件,不是输出一张截图。正文、图片、注释修改要先应用到 PDF 文档对象,再生成页面内容并序列化。

页面中的编辑与注释修改
  → 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 是已按项目约定换算的基础页面尺寸,viewScale 是用户缩放比例,dpr 是设备像素比。显示尺寸 = 基础尺寸 × viewScale;渲染像素尺寸 = 显示尺寸 × dpr。

下面这个独立函数只负责设置底图 Canvas 尺寸,方便看清两种尺寸的关系;不包含内部 PDFCore 的实现,也不要用它手动调整 Fabric 管理的 Canvas。

function sizeCanvasForPdf(canvas, baseSize, viewScale, dpr) {
  // baseSize 已换算为基础显示尺寸,不能把未经换算的 PDF 单位直接传入。
  if (![baseSize.width, baseSize.height, viewScale, dpr]
    .every(value => Number.isFinite(value) && value > 0)) {
    throw new RangeError('页面尺寸与缩放比例必须为正数');
  }
  const cssWidth = Math.max(1, Math.round(baseSize.width * viewScale));
  const cssHeight = Math.max(1, Math.round(baseSize.height * viewScale));
  canvas.style.width = `${cssWidth}px`;
  canvas.style.height = `${cssHeight}px`;
  // 修改 width/height 会清空 Canvas,随后要重新渲染页面。
  canvas.width = Math.max(1, Math.round(cssWidth * dpr));
  canvas.height = Math.max(1, Math.round(cssHeight * dpr));
  return { pixelWidth: canvas.width, pixelHeight: canvas.height };
}

// 可在浏览器控制台运行;该示例只演示尺寸,不会打开 PDF。
const demoCanvas = document.createElement('canvas');
const sizes = sizeCanvasForPdf(demoCanvas, { width: 600, height: 800 }, 1.5, 2);
console.log(demoCanvas.style.width, sizes.pixelWidth); // 900px、1800
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

真实调用中,把 pixelWidth/pixelHeight 交给 renderPixmap(),内核生成相同尺寸的 ImageData,再执行 ctx.putImageData(imageData, 0, 0)。putImageData 不受 Canvas 变换矩阵影响,所以只调用 ctx.scale(dpr, dpr) 不能把低分辨率的 WASM 像素变高清。普通 fillText()、路径绘制可以用绘图变换适配 DPR,二者不要混淆。

高分辨率画布按较小 CSS 尺寸显示本身是正常做法;问题是先把高清图压进低分辨率画布,又把这张低清图放大。细线可按最终设备像素检查对齐,但不能把所有小数坐标都当成错误,也不能按操作系统写死 DPR。

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

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

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

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

# 为什么在线注释统一按未旋转页面保存

用户可能在 0°、90°、180° 或 270° 页面上画批注。如果把当前屏幕坐标直接保存,其他用户换一个旋转角度打开时,批注就会错位。

项目记录 pageRotationUserMap: Map<pageId, accumulatedAngle>,创建或更新注释时把位置逆旋转回未旋转页面;还原时按当前显示角度正向转换。这里的统一规则是在线注释按页面未旋转时的位置保存,而不是按某台设备当时的屏幕位置保存。

对象 保存前怎样处理 关键函数或细节
矩形、椭圆 显示坐标归一化 → 逆旋转 → 映射到原页面尺寸 mapPageRotatedObjectToOriginal();处理 left/top/width/height
画笔路径 每个控制点转为绝对坐标 → 归一化 → 逆旋转 → 重建路径 transformPencilToOriginal();重算包围框、局部路径和 pathOffset,修正 angle
线段、箭头 对两个端点逆旋转,再重新计算位置和包围框 computeOriginalFabricForLine();箭头还要考虑头部延伸范围
原生 PDF 注释 通过 PDF 页面矩阵转到显示坐标 不把 PDF 单位和在线画布像素直接混用

辅助函数 rotateRelativePosition(rx, ry, degrees) 围绕归一化页面中心 (0.5, 0.5) 旋转。以显示坐标中顺时针 90° 为例,点 (0.2, 0.3) 会变成 (0.7, 0.2);保存时再做逆向变换回到原位置。

画笔不能只改包围框:路径中的各点仍然代表具体笔画,必须一起变换。文字也要注意 Fabric 的中心点与左上角位置换算,否则缩放或旋转后保存的位置会偏。

# 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() 恢复图标并关联评论数据

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

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

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

导出则反向执行:convertFabricAttrsToWasm() 把 Fabric 对象转成 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() 用于限制对象不要越出页面。旋转对象要检查变换后的包围框;不能只判断未旋转的宽高。

还原流程中记录了 deletePageAnnot():把可支持的原生注释交给前端管理后,移除内核旧副本,避免底图与交互层重复显示。这属于对象管理权的迁移,不是打开文件就删除所有注释。必须完整保留可重建的数据并在导出时写回;不支持的注释应保留原生表示,不能为了可编辑而丢失内容。

# 对象旋转、翻转为什么要用矩阵

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

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

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

# 如何修改 PDF 原有文字

PDF 原文不是一个普通 DOM 文本框。先由内核分析布局,再用 Fabric 显示可编辑区域和选择状态,实际文字修改仍由内核完成。

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

RenderEditBlock 为文本块建立透明的 Fabric Rect,绑定 selected/deselected/modified/mouseup 等事件。点击先选中区域,双击或编辑操作进入输入状态,激活 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

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

# 原生注释怎样创建与刷新

读取已有注释时,AnnotateManager.getAnnotateList() 调用 page.GetPageAnnots(),遍历页面注释的内容和外观,再由 AnnotateFactory 创建相应渲染对象。

新增文字注释时,AnnotateManager.addAnnotateText() 调用内核的 PageAnnotWriter 相关能力,再由 IPDFGraphics.DrawVariableText() 在注释外观中绘制文字。PDFFactory.CreateVariableTextEditor() 与 textEditor.LoadAnnot() 支持读取段落和片段属性,例如字体、字号、颜色、粗体和斜体。输入最后仍通过内核的 InsertText() 写入文档。

外观更新后执行 appearance.ClearCachedAP()、form.GenerateContent(),再刷新页面。AP 是 Appearance,即注释外观;清理旧外观缓存后重新生成,避免属性改了但屏幕仍显示旧样式。

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

Block.getContentObjList() 遍历 page.GetContentObjects(),找到 IPDFContentObjectKindImage 类型。RenderEditObjectImage 在交互层建立相应对象,用户结束调整后把变换交给内核。

  • 移动通过平移矩阵表达,例如 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 内核提供的 ReversibleOperation,保存可逆的文档修改
execute() 执行操作入口
executeUndoCb() 撤销后恢复光标、对象与界面状态
executeRedoCb() 重做后同步对应状态

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

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

# 编辑状态具体保存什么

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

# 字体为什么按需加载

PDF 里的字体与浏览器默认字体不是同一回事。原文显示正常,也不代表新增文字已经有可用的字体资源;替换字体还可能改变字宽、行数和换行位置。

项目的 FontManager 在内核请求未加载字体时,通过 OnlineFontMatch 回调查找匹配字体,fetchFont(url) 从 CDN 下载,再由 PDFFactory.CreateFontFromFile() 创建内核字体资源,并缓存到 fontMatchCache。

这样不需要打开文档时把所有字体一次性下载。失败时仍应有可解释的回退和提示;缺字、字体替换与布局变化要一起检查,不能只检查下载请求成功。

# 二、AI 翻译回填

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

# 翻译的完整链路

上传 PDF → WASM 解析和布局分析 → 按段落提取原文
  → 分批调用翻译 API → 按原顺序关联译文
  → 保留格式并替换原段落 → 检查排版、调整字号
  → 重画预览 → 生成并导出 PDF
1
2
3
4

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

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

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

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

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

IPDFDocSecurityCheckPassword(doc.GetSecurity(), password, true) 属于密码与权限检查链路;IsScannedDocument() 用来检测扫描文档,检测扫描件不等于执行 OCR。OCR 是 Optical Character Recognition,即把图片中的文字识别出来。资料说明了这个预处理流程,但没有确定 OCR 引擎、模型和部署位置,不能直接归功于 WASM 的段落识别。

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

同步滚动的稳妥设计是按 “页码 + 页内相对位置” 对齐,并区分主动滚动与程序设置,防止两栏互相触发循环。这是实现时应遵守的规则,不把未展示的滚动源码当成已验证事实。

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

Block.getBlockInfoList() 先分析文本块,getParagraphs() 再取得块中的段落。RecognizeUsingParagraphMode 用于布局与段落识别,不代表已经实现对扫描图片的 OCR。

Block 是布局块,一个块可能含多个段落;Paragraph 是这条翻译链路的最小处理单元。每段同时保留原文、内核对象、边界、光标和行数,译文返回后才有准确写回的位置。

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

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

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

如果 PDF 没有可提取的文字层,例如纯扫描图片,需要另外的 OCR 流程;不能把段落提取直接说成已经支持所有扫描件。

项目的翻译流程通过前面的 OCR 预处理支持扫描文档,随后复用同一条文字提取与回填链路。识别错误、阅读顺序错误仍可能传递到译文,不能只检查是否提取到了字符串。

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

块记录还保留 UUID、块类型、paragraphList、当前 scale,以及 ow/oh/ol/ot(除去显示缩放后的宽、高、左、上位置)。显示坐标服务于界面定位,PDF 四角与光标服务于内核写回。对于旋转页面,需要完整页面变换,不能只除一个 scale 就当成 PDF 坐标。

# 批次、并行分组与接口字段

AITranslator.vue 的 startProcess() 按有文字的页面处理段落,每批最多 90 段;批内通过 convertTo2DArrayBySubArrayCount(arr, 3) 分成三个子数组,满批时约为 30 + 30 + 30。传给后端的是二维数组,后端可按三个子任务处理。

这里不是前端同时发出三个 HTTP 请求,也不是所有页面一起无限并发。资料中的外层调用按页面、批次等待 batchTranslate(),三组并行是请求内的任务分组。段落数少时,分组长度自然更短。

接口为 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 标记用于避免失败后反复弹出相同错误提示,不等于错误已经恢复。

# 译文回填的六个步骤

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() 比较新旧高度,必要时减小字号 译文长度改变后,避免直接挤出原区域

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

# 字号自适应做了什么、不能保证什么

译文比原文长时,同样字号可能导致行数增加。资料中的做法是比较插入后的高度与原段落高度;超出时每次减少 0.1 pt,重新排版并测量,直到高度差落入约 5 pt 的容差,或触及 0.5 pt 的保护下限。达到下限仍异常时,恢复原字号。

pt 是 point,即字体的磅值,1 pt 为 1/72 英寸。0.1 pt 是调整步长,5 pt 是高度容差,0.5 pt 是算法保护值,不是适合用户阅读的字号标准。

这是原位回填的启发式方案,不保证所有文档完全保持原排版。恢复字号也不代表溢出已经解决:译文过长、字体缺字或复杂排版时,需要提示用户、提供人工调整或其他版式处理。生产调整还应设置最小可读字号和迭代上限,避免无限缩小或长时间阻塞。

翻译实现资料还记录了按原行数限制的做法:先比较 paragraph.GetLines().GetSize() 与记录的 lineCount,译文行数更多时,每次减少 0.1 pt 并调用 SetFontSize(),重新取得段落边界、光标和范围,直到不超过原行数或达到保护下限。

高度判断与行数判断是两份实现记录中的适配策略,不能说成同一段代码同时执行。高度更直接关注占用空间;行数便于控制段落扩张,但相同行数不保证相同高度,也不保证混合字号、行距完全一致。二者的共同思路都是根据内核重新排版的结果判断,而不是按译文字数猜字号。

# 从译文到下载文件

调用方 动作 结果
AITranslator.vue startProcess() 获取段落并调用 batchTranslate() 段落记录关联译文
Block.ts setAllTranslateBackText() 把译文写入内核中的原段落
页面管理 refreshCurrentPageNotNew() 重新渲染预览,检查替换后的页面
PDFDocument.ts saveForAITranslate() 生成各页内容、保存字节流并下载 PDF

预览与导出都基于修改后的文档对象。不能只在 Fabric 里覆盖译文,否则下载文件仍可能包含旧原文。

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

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

HiPDF 的核心需求不只是显示译文,还要把它写进可下载的 PDF,因此复用 PDFCore 更贴合现有架构。HTML 路线适合重视阅读展示的场景,但不能简单断言它一定更快、支持所有语言,或完全不能保留文字结构。技术调研中的竞品观察也不能替代对方源码证据。

# 三、协同注释与评论

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

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

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

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

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

操作 处理过程
创建 绘制对象 → 规范化数据 → 本地 Store → API 保存 → 房间通知
更新 updateAnnotationData() → 重新计算外观与位置 → API 更新 → 通知其他用户;资料中使用约 1000 ms 节流
删除 removeAnnotationElementByFrontId(frontId, pageNum) → 移除 Store 和画布对象 → API 删除 → 房间通知
重新打开 getAnnotationList() → renderAnnotationOnCanvas() → 按类型还原 Fabric 对象并建立 ID 关联
收到创建 通过同一还原入口重建对象,而不是另写一套远端绘图逻辑
收到更新 按 ID 移除旧对象,再按新数据重建;对应整对象替换,不是字符级合并

节流用于减少拖动期间的高频请求,鼠标释放后仍应提交最终状态,不能让最后一次位置变化丢失。页面关闭、API 失败时也需要处理未保存状态。

# 注释记录中的字段是什么意思

以下是类型摘要,用于说明记录结构;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 把接口数据整理成界面需要的页、数量、评论和回复列表,并显示用户名、时间、内容和编辑状态。

操作 前端与服务调用 通知
添加回复 构造 reply、本地追加,调用 createAnnotationComment() createReply
修改主评论 commentDataStore.updateContent(),调用 updateAnnotationComment() updateMainComment
修改回复 更新对应 reply,再调用更新接口 updateReply
删除评论或回复 本地移除,调用 deleteAnnotationComment() deleteReply 等对应动作
权限判断 canEditOwnComment、canDeleteOwnComment、canDeleteOthersComment 文档 Owner 可具有删除他人评论的权限

本地先更新可以让输入反馈更快,但失败时要恢复、标记失败或重新获取服务端状态。按钮是否显示只是交互控制,修改和删除权限仍应由后端根据真实身份与文档权限校验。

# 从连接到离开房间

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

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

业务 OpCode 含义 处理器
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):按业务 OpCode 分发
  → 房间消息中的 annotationHandler
  → annotationNotifyHandler(message)
  → 过滤自己发送的回显
  → 按 action 分发注释或评论处理器
1
2
3
4
5
6
7
8
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 分别维护注释与评论的动作映射。业务 OpCode 先决定消息大类,action 再决定具体动作,避免所有逻辑挤进一个巨大的消息回调。

发送消息包括 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

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

图形的坐标、尺寸与路径很难自动合并,因此项目采用对象级编辑锁:用户 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

对象可能还没随虚拟列表创建。waitForFabricCanvas() 有限重试查找,资料中最多约 10 次,等待间隔随 200 ms × attempt 增长。这是在等待本地画布就绪,不是网络请求重试,也不是无上限指数退避。

这个前端机制能让已收到锁的用户停止操作,但严格互斥还需要服务端先裁决谁拿到锁。若两个人同时选中,只靠互相广播仍可能发生竞态。可靠方案应由服务端原子授予对象锁,更新接口检查拥有者与租期,客户端再显示获准后的状态。

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

场景 项目处理方式 需要注意什么
显示拥有者 createLockBorder() 建立透明 Rect,以用户颜色描边,约 5 像素留边 边框带 lock-border-${targetObj.id};不可交互,excludeFromExport: true,不能导出成 PDF 内容
对象变化或删除 监听 object:modified 与 object:removed 更新边框或同步移除,避免留下悬空装饰
A 取消选中 广播 UnLock,移除本地锁 B 恢复 selectable/evented 并调用 removeLockBorder()
A 离开或断连 annotationUnLockLeaveHandler(fromUserId) 找到其全部锁并释放 对应 Map<annotationId, { wsId, timestamp, pageNum }>;异常失联还需要租期兜底
新用户加入 syncLock 触发 getEditingElements(),逐个应用当前锁 只读注释快照还不够,还要知道谁正在编辑

120000 ms 即 2 分钟,是资料中的默认锁租期。它能为异常离开提供超时边界;长时间编辑是否续租、由谁判断过期,应由服务端锁协议定义,不能只相信浏览器时间。

# 乐观更新和乐观锁不是一回事

乐观更新是先更新界面,乐观锁是在提交时检查版本;编辑锁则是先取得某个对象的编辑权。这三个概念解决不同问题。

机制 怎么做 在本项目中的作用
乐观更新 API 返回前先修改本地 Store 批注与评论立即显示,失败时再恢复或提示
对象级编辑锁 编辑前申请锁,其他用户暂时不能改同一对象 减少拖动、缩放、路径修改相互覆盖
版本号乐观锁 更新时携带原版本,服务端发现版本变化则拒绝 可作为并发兜底,但不能把普通本地更新说成已经实现了版本校验

界面可以乐观更新,同时由编辑锁保护对象,二者并不矛盾。它也不是字符级协同编辑,不能直接等同于 OT 或 CRDT:本项目主要同步整个注释对象与评论动作。

# 性能、失败处理与验证重点

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

问题 对应措施 边界
长文档同时创建大量画布 DynamicScroller 虚拟滚动,只创建可见页的 FabricCanvas 离开页面释放显示资源,但保留可重建的数据与锁状态
高分屏与放大后模糊 底图按新比例渲染,Fabric 使用 Retina 支持 DPR 越高内存越大;业务缩放不能重复乘 DPR
拖动触发大量接口 更新约 1000 ms 节流,拖动结束提交最终状态 节流不是丢弃最终修改,失败要可见
多批翻译请求过重 每批最多 90 段,分三组;逐页、逐批处理 段落数不等于 Token 数,长段落仍可能超限
字体资源过大 CDN 按需加载并缓存 缺字与加载失败要有回退;页面排版可能变化
太多批注影响交互 单页 100 个、文件 800 个的策略限制 数量限制要前后端一致,不能只依赖按钮校验
画布尚未创建就收到锁 有限重试,画布还原时再应用已有锁状态 重试到上限后不能直接丢掉锁语义

WASM 本身不保证不卡顿。内核调用、像素复制和字号调整仍可能占用主线程;确认瓶颈后再考虑分页调度、缓存或 Worker,不能把使用 .wasm 等同于自动多线程。

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

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

历史记录 数值 怎样准确描述
Brotli 压缩文件 14.3 MB → 3.8 MB 压缩后传输体积减少约 73%;解压后的内核并没有变小
未使用本地缓存的加载样本 平均 4011 ms 当时环境中的少量测量,不是所有用户的平均值
使用 IndexedDB 后的加载样本 平均 927 ms 相比该组未缓存样本约减少 77% 耗时,不代表 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 MIME 类型。只把文件改名为 .wasm.br,不会自动变成可执行的 WASM。现代主流浏览器支持 Brotli HTTP 解码,线上一般通过 HTTPS 使用;不支持或资源配置异常时需要有未压缩文件的受控回退。

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

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

Response 的流不能消费两次,因此要在消费前 clone();缓存读取的是解码后的 ArrayBuffer,不是压缩的 .br 字节。缓存命中仍要编译和初始化,不能说成直接得到已运行的 PDF 编辑器。

WASMCache 使用 IndexedDB:数据库名 WASMCache、对象仓库名 wasmFile、资源键 PDFCore_Wasm,记录形如 { version: fileVersion, data: arrayBuffer }。getWasm() 只在文件版本匹配时返回数据,否则返回空,重新下载;资料中的内核版本示例为 9.2.0.5025。

  • 两种版本分开:IndexedDB 的数据库结构版本是正整数,例如 1;内核文件版本是字符串。更新内核时改 fileVersion,不能把 9.2.0.5025 当成数据库版本。
  • 缓存不是前置硬依赖:私密模式、配额不足、数据库读写失败时仍允许网络加载。put() 请求成功不等于事务已提交,写入完成以事务 complete 为准。
  • 降级必须有限:无流式实例化或 MIME 不正确时,可用已取得的字节实例化;压缩资源不可用时可尝试原始 .wasm。编译失败还可能来自损坏模块、imports 不匹配或安全策略,不能统一归为不支持 Brotli,更不能无限重试。
  • 发布要成套:匹配的胶水文件、WASM 与版本标识一起发布;调整胶水代码后重新压缩构建产物,检查首次加载、缓存命中、旧版本失效与失败回退。

IndexedDB 的通用存储背景见 浏览器本地存储。这里选择它,是因为可以异步存放十几 MB 的二进制;不是说正常配置的 HTTP 缓存无效。维护应用级缓存的价值在于明确控制版本和读取路径,代价是多维护一套失效与失败逻辑。

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

数百页 PDF 如果一次性创建所有 Canvas 和文本层节点,首屏、滚动和内存都会受影响。虚拟滚动保留完整页面数据,但只挂载可见区域与少量缓冲区域的显示资源。翻到其他页面时再创建画布、渲染与还原批注。

项目采用 Vue 3 Composition API 与 vue-virtual-scroller (opens new window) 的 DynamicScroller。它适合页面尺寸、文本层高度不完全一致的列表,比自己维护所有高度测量、节点复用和滚动偏移更省维护成本;并不意味着任何自写虚拟列表都性能较差。安装时要选与项目 Vue 版本匹配的分支,不能把它的 Vue 3 用法直接贴进本网站的 VuePress 1 运行环境。

对象或配置 项目用途
allPages 完整页面列表,项目结构包含 id 和 pageNum,顺序保持稳定
viewPageRenderMap Map<number, boolean>,记录已渲染页;单个布尔值不包含缩放和文档版本信息
DynamicScroller 管理可视范围、列表总高度与滚动定位
DynamicScrollerItem 通过 { item, index, active } 关联页面和测量状态;重型渲染还需结合可见范围调度
min-item-size 提供初始高度估计,实际高度仍需测量
emit-update 启用范围更新通知,供业务层调度可视页面;不要在每次通知中重画所有页
scrollToItem(pageNum - 1) 按页码跳转到列表下标;这条调用约定页码从 1 开始、下标从 0 开始

调度时先加载可视区域核心页面,再预渲染上下少量页,避免快速滚动时空白;具体缓冲数量按设备内存和页复杂度调整。页面点击、文字复制与跳转仍依赖对应页的数据,不能为减少节点丢掉文本顺序。

难点不只是 “少创建几个 DOM”:

  • 高度变化:缩放、旋转后重新测量;保留当前阅读页及页内偏移作为锚点,防止重算高度后突然跳页。
  • 节点复用:稳定的页 ID 与 DOM 绑定;切换页时清旧像素、解除旧事件与观察器,避免上页批注留到下页。
  • 异步过期:渲染结果回来时检查文档、页码和本次渲染标识,不能把旧文档或旧倍率的结果写到新画布。
  • 缓存失效:渲染缓存至少区分文档、页、缩放、DPR、旋转和内容变化。只记录 “这一页渲染过” 会导致编辑后仍显示旧内容。
  • 资源释放:回收显示用 Canvas、Fabric 实例和临时内核资源;页面业务数据、未保存修改与锁状态独立保留,不能一起清掉。

原性能方案提出初始渲染由约 3 秒降到 500 ms 内、DOM 减少 90% 以上的目标,这是设计目标,不是已有实测成果。评估应固定文档、设备、网络和缓存条件,记录首个可见页完成时间、滚动掉帧、内存峰值;不要用 WASM 加载时间替代整页体验。

# HTML 报告导出:怎样保留文字与分页

这部分是新报告的 PDF 导出选型,与编辑原 PDF、翻译后内核保存是不同场景。报告内容来自接口,需要按句子 ID 设置不同高亮色,文字可选择、复制,并作为文本对象保留;还要处理长文分页、多语言字体,以及首页顶部无留白、后续页预留约 20 px 的版式。

先区分两条路线:页面截图嵌入 PDF,保留的是图像;生成 PDF 文本或用浏览器打印,才有机会保留文字结构。能选择复制文字,也不代表 PDF 就拥有 Word 一样的段落编辑体验。

方案 怎么生成 适用场景与取舍
html2pdf 等截图路线 HTML → Canvas/图片 → PDF 便于复现简单视觉;文字变像素,长图容易切断句子,高清时内存较大
jsPDF (opens new window) 的 text() 直接写入 PDF 文本、颜色与位置 适合前端结构化报告;需处理字宽、换行、分页与字体,不是自动复制 HTML 样式
Puppeteer (opens new window) 的 page.pdf() Chromium 按打印样式生成 PDF 复用 HTML/CSS 与浏览器排版,可保留普通文本;需运行浏览器进程,控制并发和字体就绪
Puppeteer 截图 + jsPDF 先截图,再把图片放进 PDF 仍是位图路线;不能与 page.pdf() 混为一谈
Prince (opens new window) HTML/CSS 打印排版引擎 专业分页、页眉页脚与多语言排版;需要商业授权与服务部署
wkhtmltopdf (opens new window) 基于旧 QtWebKit 的 HTML 转换 可了解历史方案;官方仓库已归档,新方案优先评估维护中的工具
PDFKit (opens new window) 用代码生成文本、图形与页面 常用于 Node.js 报告,支持文字换行与字体;复杂模板仍要编写布局逻辑

资料中的 htmlToPdf 没有明确包名,不能仅凭名称判断所有同名工具都是截图实现。是否保留文字,要检查实际生成链路与输出,而不是给整个库统一贴 “不可编辑” 标签。

若用 jsPDF 文本路线,实现重点是:按句子 ID 关联颜色 → 用字体度量分行 → 计算每行高度 → 空间不足时新建页面 → 设置该页顶部留白 → 绘制高亮背景与文字 → 嵌入覆盖目标语言的字体。复杂文字还要验证字形组合和阅读顺序;不能只用英文字体测中文、阿拉伯文等语言。

若已有成熟 HTML 模板并允许服务端生成,可以评估 Puppeteer 打印或 Prince;若报告结构简单且要纯前端导出,可以评估 jsPDF。这份资料属于方案调研,不表示 HiPDF 已同时接入表中的全部工具。最终依据是版式、文字语义、性能、服务端条件与开发成本,不能根据竞品外观就断定其实现。

# 失败时怎样避免界面与服务端不一致

异常 处理重点
本地已创建,但 API 失败 恢复或标记未保存对象,让用户知道不能当成已经保存
API 成功,但广播失败 持久化记录仍可通过重新查询恢复;重连需要重新对齐状态
收到自己发的回显 比较 senderWsId 和当前连接 ID,避免重复应用本地操作
同一远端消息重复或乱序 用对象 ID 配合服务端版本/消息 ID 去重和排序;只过滤自己消息不够
离线用户继续拖动 重连后重新获取注释和锁,检查编辑权,不能直接覆盖在线用户的新数据
用户失联未解锁 成员离开通知与锁租期共同兜底;不能只依赖正常关闭事件
翻译数量不匹配、超时或取消 不按错误下标回填;恢复任务状态,清理会话,必要时重试可识别的批次
回填中途失败 避免直接导出半成品;保留原文档或可恢复检查点,并提示失败范围

前两项说明 API 与 WebSocket 不是一个原子事务。合理分工是API 保存权威数据,WebSocket 加速其他人的界面同步,重新查询负责修复漏通知。

服务端版本、消息 ID、锁裁决和回填恢复是可靠性设计要求;仅凭前端函数名称不能证明后端已经具备这些保证。

# 项目应该重点验证什么

以下是针对这套实现的验收清单,不是未经核对的测试框架使用记录。

范围 验证案例 通过标准
文档渲染 普通、加密、旋转、多页文档;不同 DPR 页面清晰,密码与错误提示正确,画布资源可释放
坐标往返 四种旋转角、缩放、矩形/路径/箭头 保存后重新打开位置一致,点击和选区不偏移
工具互转 11 类工具逐个创建、保存、还原、导出 文字、路径、样式和 ID 不丢失,锁边框不进导出文件
正文与图片编辑 插入、移动、缩放、删除,再撤销重做 内核内容与界面一致,保存后仍保持修改
翻译批次 0、1、89、90、91 段;三组长度不同;响应乱序和缺项 段落不漏、不串位;空文本不发无效请求
翻译布局 长译文、多字体、缺字、狭窄段落 不无限缩字号;溢出与失败可见,导出可重新解析
翻译入口与对照 未登录、加密、扫描件、额度不足、浏览器默认语言 前置条件明确;OCR 后可提取文字,两栏滚动不循环跳动
WASM 加载 首次请求、缓存命中、版本变更、配额失败、错误 MIME、损坏模块 版本匹配,缓存失败不阻断加载,回退有限且错误可定位
虚拟页面 快速跳页、连续缩放、旋转、切换文件、页面回收 不错页、不留旧批注、编辑不丢失,过期结果不覆盖新画布
报告导出方案 长句跨页、分句高亮、多语言、首尾页留白 文字可选择复制,分页不截断内容,字体与颜色正确
多人协作 两端同时选中、更新、删除,失联、重连、新成员加入 不错误覆盖,锁可恢复与过期,最终数据与 API 一致
评论和权限 添加/修改/删除主评论与回复,尝试越权 评论关联正确,后端拒绝无权限操作

# 技术难点怎样讲

# 难点一:页面能显示,但批注点不准或保存后漂移

  • 现象:页面放大、旋转后,鼠标点中的位置与文字/批注不一致;保存后重新打开,位置又变化。
  • 原因:鼠标是屏幕坐标,PDF 内核是页面坐标,在线批注还需要统一到未旋转页面;混用原点、单位或重复乘 DPR 就会偏。
  • 处理:把正反转换集中在 CoordinateConverter 和旋转适配层,绘制与点击走同一套变换。用四种旋转角和不同缩放验证位置往返,而不是给每个工具单独加偏移补丁。

# 难点二:翻译正确,回填后却丢样式或挤出原区域

  • 现象:译文拿到了,但删除原文后插入失败,或者字体、颜色和位置变化;译文变长还会溢出。
  • 原因:PDF 不像 DOM 可以直接替换字符串,文本依赖内核光标、段落边界和字体布局;删除操作可能改变这些信息。
  • 处理:删除前保存边界与格式,删除后恢复区域并带格式插入,再测量新高度、有限调整字号。核心是 “先保留定位与样式,再替换内容”,缩字号只是最后的排版补偿,不承诺完全无损。

# 难点三:两个人拖动同一个图形,后一次覆盖前一次

  • 现象:同一注释被多人同时改坐标或尺寸,界面跳动,最终状态取决于最后收到谁的修改。
  • 原因:整个图形的路径与位置不容易像普通字段一样自动合并,WebSocket 只负责传消息,不负责判定谁可以编辑。
  • 处理:采用对象级编辑锁,远端禁用交互并显示拥有者,取消选中、离开和租期到期时释放。严格防冲突还要服务端原子授锁并校验更新权限,不能把前端禁用当成完整互斥。

# 难点四:长文档一打开就卡,翻译还会进一步增加压力

  • 现象:很多页面同时建画布占用大量内存,拖动和滚动变慢;翻译请求与回填进一步增加计算和等待。
  • 原因:高清画布像素多,批注对象也有缓存;WASM 并不会自动脱离主线程,字体和像素复制还有额外成本。
  • 处理:可见页才创建画布,离开后释放;字体按需加载;更新节流但保留最终提交;翻译按批次处理。再用内存、滚动响应和单页回填耗时判断是否需要后台计算,而不是直接堆 Worker。

# 难点五:大体积内核拖慢首次与重复访问

  • 现象:页面界面已经出来,但 PDF 内核还没准备好,重复访问仍要等待。
  • 原因:约 14 MB 的 WASM 下载与初始化都需要时间,脚本下载完成也不代表内核可调用。
  • 处理:Brotli 把传输文件压到约 3.8 MB,再用带文件版本的 IndexedDB 缓存减少重复下载。运行时就绪后开放功能,缓存失败走网络、旧版本失效。历史缓存样本平均由 4011 ms 降到 927 ms;这个数衡量内核加载,不是整站首屏。

# 难点六:注释预览正常,导出后却重复或变形

  • 现象:图形在前端能拖动,保存后却出现两份,或旋转、透明度与原来不同。
  • 原因:Fabric 对象与 PDF 注释是两种表示,既要转换坐标与外观,也要明确由谁管理当前注释;两端都保留旧副本会重复渲染。
  • 处理:集中维护写入、读取和绘制映射,旋转翻转用统一矩阵。迁移到前端管理时完整保存可重建数据,再移除对应内核副本,导出前写回;不支持的类型保留原生表示。验收要保存后重新打开,不能只看编辑时预览。

# 高频面试题

1. 用一分钟介绍一下 HiPDF。参考答案

HiPDF 有三大功能:PDF 编辑与注释、AI 翻译回填、协同注释与评论。第一块让用户修改文字、图片和批注,并保存 PDF;第二块按段落提取原文、分批翻译,保留位置和格式写回,再导出译文 PDF;第三块通过 API 保存、WebSocket 同步,让多人讨论和修改批注,并用对象级锁减少冲突。底层共用 Vue、Pinia、Fabric 和 WASM PDFCore,但三块的难点分别是坐标与编辑、翻译保版式、协作一致性。

2. 为什么用 Fabric,而不是全部用原生 Canvas?参考答案

PDF 编辑器需要反复选中、拖动和缩放批注。原生 Canvas 只保留像素,命中检测、对象状态和控制点都要自己做;Fabric 已有对象模型和交互能力,可以把精力放在工具与文档转换上。但 Fabric 不是 PDF 引擎,改原文和导出仍由 PDFCore 完成,不能把画布中的文字对象直接当成 PDF 正文。

3. WASM 在项目里做什么?用了它就一定快吗?参考答案

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

4. 怎样保证 PDF 页面和批注高清且不偏移?参考答案

高清和定位分开处理。底层页面按显示尺寸、DPR 和业务缩放准备足够的渲染像素,上层 Fabric 开启 Retina 支持,不重复乘 DPR。两层共用页面尺寸、旋转和视口变换;绘制从 PDF 转到显示坐标,点击反向转回 PDF。在线注释再统一保存到未旋转页面,换显示角度时重新映射,这样不会把某台设备的屏幕位置写死。

5. 直接修改 PDF 原有文字,与添加文字批注有什么区别?参考答案

修改原文要让内核找到文本块、段落和光标,再改文档中的文本对象;文字批注则是额外增加一个注释对象。界面上看着都像文字,但保存的数据不同。HiPDF 用 Fabric 显示可编辑区域和选中状态,用 EditInput 收集输入,再通过 PDFCore 的 InsertText 等接口修改原文,刷新预览并生成文档内容后保存。

6. 新增注释怎样保存,重新打开又怎样还原?参考答案

用户画完后,先检查权限和数量,把坐标还原到未旋转页面,再由 buildAnnotAp 抽取位置、路径与样式。记录用 front_id 关联画布对象,写入本地 Store,调用 API 保存并通知房间用户。重新打开时从 API 读记录,由 renderAnnotAp 按类型重建对象。保存与还原共用映射层,所以新增工具也必须配套这两个方向。

7. 为什么矩形和画笔不能用同一种旋转还原?参考答案

矩形主要由位置和宽高描述,画笔则是一串路径点。只把画笔的包围框转回去,内部笔画仍处于旧坐标系,重新打开就会偏。项目逐点转成绝对位置、归一化并逆旋转,然后重建局部路径、包围框和 pathOffset;线段与箭头则处理端点,箭头还要计算头部范围。

8. 撤销重做怎样做到不只是恢复画面?参考答案

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

9. AI 翻译怎么保留原文的位置与样式?参考答案

不是把整页发给模型后再凭坐标盖译文,而是按段落保留内核对象、边界和光标。译文返回后,先保存字体和颜色等属性,再删除原文,恢复段落边界,带属性插入译文,最后测量高度并有限调整字号。这样定位和文件修改都在内核里完成。但长译文和复杂排版仍可能溢出,不能承诺完全保持版式。

10. 为什么按 90 段分批,再拆成三个子任务?参考答案

分批限制单次请求的工作量,三组让后端可以并行处理;前端仍按页和批次等待结果,不是同时发出所有请求。90 段是项目策略,不是通用最优值,长段落还要关注 Token 和接口上限。结果必须按原分组顺序收集,检查数量后再与段落关联;如果按任务完成顺序拼接,就可能把译文写进错误段落。

11. 为什么协作既需要 API,又需要 WebSocket?参考答案

API 保存能重新查询的批注和评论,WebSocket 让别人立即看到变化,二者职责不同。广播不是持久化,成功发消息也不能证明数据库已保存。比如 API 成功但对方断线,重新打开仍可以查到记录;重连时要重新获取快照和锁来修复漏通知。也要处理本地先显示、API 却失败的情况,不能让用户误以为已经保存。

12. 你们的协作锁能保证两个人不会同时修改吗?参考答案

前端收到 lock 后关闭对象交互、显示拥有者,能减少同时操作;但只靠广播不能严格解决两人同时选中的竞态。严格保证需要服务端原子授予对象锁,更新时校验拥有者和租期,前端拿到许可才进入编辑。正常取消选中释放锁,断连和租期到期兜底,新成员加入还要同步已有锁。这才是完整的互斥边界。

13. 乐观更新、乐观锁和悲观锁有什么区别?参考答案

乐观更新是先改界面、失败后再恢复,解决响应速度;乐观锁是提交时检查版本,发现冲突后拒绝或合并;悲观锁是先取得编辑权,再修改。HiPDF 的本地 Store 先更新属于乐观更新,对象锁采用先锁后改的思路,它们可以一起使用。但前端广播锁不是数据库事务锁,也不能把没有版本校验的接口称为乐观锁。

14. 为什么不用 CRDT 做整个 PDF 的多人编辑?参考答案

本项目主要协作的是批注图形与评论,不是多人逐字符编辑同一段正文。图形的整体路径、坐标和尺寸不容易自动合并,对象级锁更容易解释和控制;评论按独立 ID 同步。CRDT 需要设计适合的数据类型,还要处理删除、排序、权限与内核映射,不是接一个库就能解决 PDF 排版。若以后真的要多人同段输入,再单独评估文字协同模型。

15. 过滤自己发出的消息,为什么还不算完整去重?参考答案

senderWsId 只能识别本连接发出的回显,不能判断同一条远端消息是否重复,也不能防止旧更新覆盖新更新。对象 ID 负责定位,消息 ID 或服务端版本负责识别重复和过期;重连再用快照校准。WebSocket 单连接上的有序传输,也不能替代多客户端并发、API 写入顺序和重连后的业务版本规则。

16. 怎样证明编辑、翻译和协作真的正确?参考答案

不能只看当前页面显示正常。编辑要保存后重新打开,检查原文、图片和注释是否真的改变;坐标要覆盖不同缩放和四种旋转;翻译要测批次边界、错序和长译文;协作用两个客户端测同时选中、失联和重连,最后与 API 数据核对。性能再比较长文档滚动、内存和回填耗时,功能正确与界面不卡要分别验证。

17. 为什么选团队 PDFCore,而不是直接使用 PDF.js?参考答案

需求不只是预览,还包括修改原有文字、图片、翻译回填和导出。PDFCore 可以复用客户端已有的编辑与保存能力,前端负责业务和交互,减少重新实现文档引擎的工作。PDF.js 也有注释和表单等能力,但不能直接当成完整的原文编辑内核。代价是 WASM 加载、内核 API 和字体布局对接更复杂,所以需要专门做加载和渲染优化。

18. WASM 加载优化具体怎么做,收益如何衡量?参考答案

分首次和再次访问。首次用 Brotli,把约 14.3 MB 压到 3.8 MB,服务端配 Content-Encoding: br 和 application/wasm,让浏览器解压并流式实例化。成功后把解码字节存 IndexedDB,后续版本匹配就从本地读。历史样本的加载平均从 4011 ms 降到 927 ms,但缓存仍需要编译和初始化。更新内核必须改文件版本,缓存失败不能阻断使用,也不能把这些数字当成所有用户的首屏收益。

19. 为什么设置 Canvas 的 scale 还可能不高清?参考答案

要看怎样写入像素。普通文字和路径绘制可以扩大画布像素尺寸,再缩放绘图坐标。HiPDF 底图是 PDFCore 返回 ImageData 后用 putImageData 写入,这个方法不受变换矩阵影响。因此要把 CSS 显示尺寸乘 DPR 后的像素尺寸直接交给内核渲染。比如显示 900 像素宽、DPR 为 2,就让内核输出 1800 像素宽,而不是把 900 像素的旧图放大。

20. 数百页 PDF 怎么保持滚动流畅,为什么不能只加一个虚拟列表?参考答案

用 DynamicScroller 只挂载可见页和少量缓冲页,按需创建底图、Fabric 和文本层。但还要管理动态高度、页面资源和异步渲染。缩放旋转后重测高度并保持阅读锚点;回收节点时清事件和旧画面;结果回来检查页码、文档和渲染版本。缓存不能只记录某页渲染过,还要包含倍率、DPR 与内容变化,否则列表节点少了,仍可能出现错页或旧内容。

21. 扫描 PDF 怎样翻译,加密文件与额度不足怎样处理?参考答案

先做业务检查,再进入翻译。登录后上传,翻译入口对加密文档提示重新上传可处理版本;扫描件先经 OCR 转成能提取文字的 PDF,再用同一套段落提取、翻译和回填链路。发翻译请求前还检查额度,不足时引导购买。IsScannedDocument 负责检测,不是 OCR 本身;段落识别也不能把图片直接变成文字。OCR 错字和顺序错误还要在对照预览中核对。

22. 译文变长后怎样放回原区域?参考答案

先保留原段落边界、格式和行数,带属性插入译文,再由内核重新排版。资料记录过按高度和按行数判断的两种策略,超出时逐步减小字号,每步 0.1 pt,并重新读取边界与光标范围。重点是测量真实布局,不按字符数猜。但缩字号有可读性限制,相同行数也不保证高度一样,所以要有迭代上限和溢出处理,不能承诺任何译文都完全保版式。

23. 为什么还原注释时会移除内核中的旧注释?参考答案

如果原生注释交给 Fabric 编辑,但底图仍画着旧注释,就会叠出两份,移动后还留下旧影子。因此支持的类型先完整提取位置、路径和样式,交给前端管理,再移除内核旧副本,导出时重新写回。关键是管理权转移,不是简单删除。没有完整还原和写回能力的类型要保留原生数据,不能为了画布交互把用户内容丢掉。

24. 导出报告为什么不能直接把 HTML 截图放进 PDF?参考答案

报告需要文字可选择复制、句子高亮和合理分页,截图路线只能得到像素,还可能在分页处切断文字。调研时要分清:jsPDF 的 text 方法和 PDFKit 直接生成文本;Puppeteer 的 page.pdf 用浏览器打印,普通文字也可保留;Puppeteer 截图再嵌入 PDF 仍然是图片。纯前端结构化报告可评估 jsPDF,复杂 HTML 模板可评估浏览器打印或 Prince,最终还要看字体、分页、性能和部署成本。

# 相关知识与官方资料