技术文章

TPdfProgressiveDocument:边下载边打开 PDF

PDFium Component 通过 TPdfProgressiveDocument——一个包装 PDFium FPDFAvail_* 可用性 API 的 TPdf 子类——打开仍在下载中的 PDF。BeginProgressiveLoad 启动会话,CheckDocumentAvailability 报告 PDFium 还缺哪些字节范围,OpenProgressiveDocument 在字节够用时打开文件,CancelProgressiveLoad 放弃被中断的下载且不泄漏原生句柄。难的不是 happy path。不稳定连接上的阅读器会见到用户在 25% 时关掉标签页、又改了主意、再点开同一个链接,而每一个被放弃的会话都留着一个原生可用性句柄、两条 C 回调记录、一个流适配器和一批在途范围请求,必须按精确的顺序释放

TPdfProgressiveDocument 如何加载仍在下载中的 PDF?

TPdfProgressiveDocument 在随机访问流被填满的过程中保活一个 PDFium 可用性提供者,并在每个解析步骤之前询问它:想要的字节在不在。BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount) 接收后端流和远端文件的逻辑尺寸,把一个 IsDataAvail 回调和一个 AddSegment 回调接进两条记录,然后调用 FPDFAvail_Create。PDFium 问某个范围在不在时,只要该范围落在 AvailableByteCount 描述的连续前缀内、或落在已经经 RangeRequests 调度器完成的范围内,组件就答在;稀疏存储可以让 OnDataAvailable 事件推翻这个判定。每次调用 CheckDocumentAvailability 返回三个 TPdfDataAvailability 值之一(pdaAvailable、pdaNotAvailable、pdaError),并把 PDFium 要的范围以排序、合并过的 TPdfDownloadRanges 数组交回,它们已经以 rrpImmediate 优先级排进调度器

// FetchRange 是你的传输层(HTTP Range GET、socket、blob 读取器):
// 它把 Size 字节写到 Store 的 Offset 处,返回实际到达多少
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // 提示已经排进队列;先写字节,再完成请求
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

那个循环里有两处细节承重。轮次上限之所以重要,是因为死链会让 CheckDocumentAvailability 永远要同一批范围,无界循环会把一次网络故障变成卡死的界面。顺序之所以重要,是因为调度器用临界区序列化自己的状态、却对后端存储的 TStream.Position 撒手不管:传输线程必须先把响应字节写进流、再调 CompleteRequest,因为完成一发布 PDFium 就可能读那个范围,并发写方需要定位 I/O 或自己的锁

PDFium Component 中 TPdfProgressiveDocument 的可用性循环:BeginProgressiveLoad 创建 FPDFAvail 提供者,CheckDocumentAvailability 交回按 rrpImmediate 优先级排队的排序合并下载提示,传输层先写字节进存储、CompleteRequest 才把范围发布给 PDFium,循环封顶 64 轮——死链会一直要同一批范围
先写字节,再完成请求:完成一发布 PDFium 就可能读那个范围,流位置没人替你保护

AvailableByteCount 为什么拒绝后退?

AvailableByteCount 只增不减,试图缩小它时 setter 会抛 EPdfError,消息是 "Available byte count cannot move backwards"。IsDataAvail 回调一旦告诉 PDFium 某范围存在,解析器可能已经读走并缓存了其中的对象,事后收回这些字节会让可用性答案与 PDFium 已消费的内容对不上。同一个 setter 还拒绝大于 LogicalFileSize 的值,会话外抛 "No progressive load is active"——这正是为什么加载开始前就已到手的字节应该放进 BeginProgressiveLoad 的 AInitialAvailableByteCount 参数,而不是过早地赋给属性。如果你的下载存储乱序填充,那就别想着用前缀来表达它:通过调度器完成范围,或通过 OnDataAvailable 作答

部分下载的 PDF 到底什么时候能打开?

只有线性化 PDF(ISO 32000-1 附录 F,即 "Fast Web View" 布局)能在整个文件到达之前打开;非线性化 PDF 仍然需要每个字节。OpenProgressiveDocument 检查 Linearization 属性(plnUnknown、plnNotLinearized、plnLinearized)并据此路由:线性化文件在首页区和提示表到位后经 FPDFAvail_GetDocument 打开;非线性化文件走同一条文件访问记录上的 FPDF_LoadCustomDocument,且只按整体可读对待。这条路由的存在有个具体原因:对非线性化文件调用 FPDFAvail_GetDocument 可能返回一个非空句柄、页数却是零——一个看起来打开实则空空如也的文档。组件自己的测试套件里,一个 51 页的线性化夹具在稀疏下载存储尚未覆盖整个文件时就达到 pdaAvailable,并带着完整页树打开

OpenProgressiveDocument 如何路由部分下载:线性化文件在首页区和提示表到达后经 FPDFAvail_GetDocument 打开,非线性化文件需要 FPDF_LoadCustomDocument 和全部字节;LoadAvailablePage 在页检查之前先用 FPDFAvail_IsFormAvail 查表单可用性,避开非空零页句柄陷阱
只有线性化文件抢得到先机;其余情况下 FPDFAvail_GetDocument 可能返回一个看似打开、页数为零的文档,路由防的正是这个
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumber 此时已是活动页
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePage 接收从 1 起算的页码,并强制 PDFium 期待的顺序:第一次页检查之前先跑 CheckFormAvailability——它包着 FPDFAvail_IsFormAvail——之后才调 FPDFAvail_IsPageAvail。pfaNotPresent 是无 AcroForm 文档的正常答案,不阻塞任何事。页就绪后,LoadAvailablePage 把它设为活动页,于是阅读器可以在其余页面还在路上时渲染线性化手册的第 1 页;FirstAvailablePageNumber 告诉你线性化字典指定哪一页为首页,已从 PDFium 的零起算索引换算好

CancelProgressiveLoad 释放什么,按什么顺序?

CancelProgressiveLoad 用四步拆掉一个会话,顺序不可调换:取消范围调度器、关闭文档、用 FPDFAvail_Destroy 销毁可用性句柄、最后释放回调记录和流适配器。先取消调度器会推高它的代计数器、丢弃所有待处理和在途请求,并对每个在途请求触发 OnCancelRequest;晚到的传输完成带着旧代数,CompleteRequest 返回 False、什么都不碰。文档必须在可用性句柄和适配器消失之前关闭,因为 PDFium 关闭文档时可能回调进文件访问提供者,适配器若已不在,那次回调就在读已释放的内存

PDFium Component 中 CancelProgressiveLoad 的固定拆解顺序:先取消范围调度器,让晚到的完成撞上推高后的代计数器并返回 False;在文件访问适配器消失之前关闭文档;用 FPDFAvail_Destroy 销毁可用性句柄;最后才释放回调记录和流适配器
一个幂等方法同样清理失败的启动、用户取消和析构;有工作线程往存储里写时,流所有权留在你手里
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // 调度器与 FPdf 同寿,接一次线就够
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // 你的代码:关掉那个 socket 或请求
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

这个方法是幂等的,也是三种情况共用的唯一清理路径:BeginProgressiveLoad 构建到一半失败、显式的用户取消、以及析构。BeginProgressiveLoad 启动前也会调它,所以在同一个对象上换新 URL 重启不需要显式取消。有一个所有权决定必须你亲自做对:如果工作线程往后端流里写,就传 AOwnsStream = False、等工作线程停下后自己释放流,因为交出所有权后,取消会释放流,而迟到的写入可能还在路上。OnCancelRequest 里抛出的异常按请求吞掉,于是一个传输失败挡不住其余的取消

生命周期套件如何证明取消路径不泄漏?

PDFium Component 生命周期压测套件在每个混合周期里都演练一次被打断的网络式下载。每个周期启动一个渐进加载,其存储只装夹具字节的四分之一,要求得到带非空提示列表的 pdaNotAvailable,调用 CancelProgressiveLoad,并断言对象既不报告 ProgressiveLoading 也不报告 Active;随后以完整可用性把同一条流式路径跑到终点——OpenProgressiveDocument、一次渲染、一次关闭。默认混合跑覆盖 100 个计量周期,含 600 次打开、2300 次渲染和 100 次渐进取消,采样私有内存增长 8.21 MiB,预算 32 MiB。套件把渐进取消和渲染回调取消分开计数,因为被放弃的下载和提前收工的渲染循环是不同事件,验收标准也不同

渐进路径帮不上忙的地方

在上面盖阅读器之前,有几条限制值得知道。需要原始文件字节的功能面对不完整的渐进源宁可拒绝也不瞎猜:ReadXmpPacket 显式失败,签名验证报告 Indeterminate,直到整个文件到位。默认可用性测试假设连续前缀,乱序抓取范围的传输必须经 RangeRequests 完成或经 OnDataAvailable 作答,否则 PDFium 会一直要你已经握着的字节。非线性化文件在首屏时间上毫无收益,所以首次绘制要紧的话,在服务器端把文件线性化。CancelProgressiveLoad 也不会替你关 socket;OnCancelRequest 才是干这件事的钩子

按需加载完整本地文件的普通流适配器路径,见用 PDFium 按需流式加载大型 PDF;打开位于更大缓冲区内部的 PDF,见嵌入式 PDF 的 byte range 加载。取消已加载页面的慢速渲染是另一套机制,见可取消的渐进式页面渲染。TPdfProgressiveDocument 和它的范围调度器随PDFium Component for Delphi and C++Builder 交付