PDFium Component 可以直接从一段字节范围打开一份存活在更大缓冲区内部的 PDF。重载方法 LoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean) 原地寻址这个窗口,不需要预先做一次 Copy。作为交换,它要求你理解一条规则:当 Buffered 为 False 时,那个底层数组是被借用的,不是被复制的
这和用 PDFium VCL 按需流式加载大型 PDF一文里描述的回调驱动方式是两种不同的机制,那种方式给 PDFium 一个 FPDF_FILEACCESS 读取器,让它按需从磁盘拉取数据块。那种方式适用于大到装不进内存的文档。而这一种适用于已经在内存里、位于某个已知偏移量处的文档。两者是互补的,最后一节会说明具体哪种情形该用哪一种
那 40 MB 没人要求过的复制
这个场景出现在任何 PDF 被夹带在其他格式内部的地方。一个邮件存储把邮件正文和附件放在同一条记录里。一个归档容器把清单、几张图片和一份 PDF 拼接在一起。一个自定义的线路协议在一个带长度前缀的头部之后帧出一份文档。每一种情况,你最终手上都会拿到一个庞大的 TBytes,并且知道那份 PDF 从第 1182336 字节开始,长度是 312 千字节
在字节范围重载出现之前,惯用的做法是 Copy(Data, Index, Count),它会分配第二个数组,把窗口内容 memcpy 进去。然后你把这个切片交给 LoadDocument,Buffered = True,它又会把这份切片再复制一遍,进组件的私有缓冲区。同一份字节被复制了两次,其中一次纯属走过场,而在一次大型邮箱扫描中,这个动作会对每一封邮件重复一遍。字节范围重载无条件消除了第一次复制,可选地消除了第二次复制
字节范围重载实际做了什么
这个重载在设计上很薄:它做校验、算出一个指针,然后委托给整个家族本来就汇聚到的那个指针版本的 LoadDocument。Index 是从零开始的,Count 是字节长度,Buffered 默认为 True,和其他重载完全一致。单参数版本的 LoadDocument(const Data: TBytes; Buffered: Boolean) 本身现在也只是调用这一个版本,传入 Index = 0 和 Count = Length(Data),所以只有一条校验路径,而不是两条
调用它的代码看起来和你原本会写的差不多,只是少了那次切片
var
Frame: TBytes; // whole container record, tens of megabytes
Offset, Size: Integer;
begin
Frame := LoadContainerRecord('mailbox.dat');
LocateEmbeddedPdf(Frame, Offset, Size); // your container parser
// No Copy(Frame, Offset, Size) here - the window is addressed in place
Pdf.LoadDocument(Frame, Offset, Size, True);
try
RenderPreview(Pdf);
finally
Pdf.UnloadDocument;
end;
end;
为什么 Index 加 Count 会让边界检查溢出
因为 Index 和 Count 都是 Integer,而两个较大的正 Integer 值之和不一定还是一个较大的正 Integer。这是这个重载的技术核心,也是唯一一个"看起来很自然的检查"实际上是内存安全漏洞的地方。直观的写法是错的
// WRONG: Index + Count is evaluated in Integer and can wrap negative
if Index + Count <= Length(Data) then
DataPtr := @Data[Index];
// RIGHT: reject signs first, then bound each term separately,
// with the only arithmetic done as a subtraction that cannot wrap
Check(Index >= 0, 'PDF byte range index cannot be negative');
Check(Count >= 0, 'PDF byte range count cannot be negative');
Check(Index <= Length(Data), 'PDF byte range index exceeds data length');
Check(Count <= Length(Data) - Index, 'PDF byte range exceeds data length');
把失败的情形推演一遍。取 Index = 2000000000、Count = 2000000000。它们真正的和是四十亿,但在 32 位有符号算术里,结果会回绕成恰好负 294967296。这个值妥妥地小于 Length(Data),所以错误的检查通过了,@Data[Index] 被取到了数组之外老远的地方,PDFium 拿到的是一个野指针加一个两吉字节的长度。接下来发生的,运气好是一次访问违规,运气不好则是悄无声息地解析了无关的进程内存
正确的顺序通过"永不相加"来解决这个问题。负数在做任何索引之前就先被拒绝,所以 @Data[Index] 永远不会取到数组下界以下。然后 Index 单独对照 Length(Data) 做边界检查,这保证了 Length(Data) - Index 一定是一个非负的 Integer。只有到这一步,才拿 Count 去和这个余量比较。每一个中间值都始终留在可表示的范围之内,所以任何构建配置都无法改变结果。也不要指望靠 {$Q+} 溢出检查当作安全网:发布版构建里通常是关闭的,即便开着,你也只是把一个内存安全 bug 变成了一个从校验例程中间逃逸出来的 EIntOverflow。PDFium Component 对待不可信的长度算术,和它对待其余边界的方式是一致的,这套纪律在加固 PDFium VCL ABI 与 Delphi 内存安全一文中有更全面的讲述
为什么零长度窗口必须传 nil
因为对于校验能接受的每一个 Index 来说,@Data[Index] 并不都是一个合法表达式。Index = Length(Data) 配 Count = 0 是缓冲区尾部一个完全合法的空窗口,而一个空的 TBytes 在 Index = 0 时,对应的数组根本没有第零个元素。这两种情况下取地址,要么索引越过末尾,要么解引用一个 nil 动态数组。所以这个重载会分支处理:Count = 0 产出一个 nil 指针,其他任何计数产出 @Data[Index]。这个 nil 随后流入指针版本的重载,而后者自己的防护逻辑在大小为零时接受一个 nil 指针,加载最终以普通的"Cannot load PDF document"错误结束,而不是一次访问违规。一个从格式错误的容器里算出零字节窗口的调用方,得到的是一个干净、可捕获的 EPdfError,和任何其他坏输入的处理方式一样
借用还是复制:Buffered 决定的是什么
Buffered 选择的是所有权契约,它是这里唯一一个影响会延续到调用之外的参数。当 Buffered = True 时,PDFium Component 会在加载之前,把选中的窗口——而且只有这个窗口——复制进它内部的缓冲区。那 40 MB 的容器不会被复制;被复制的是那 312 KB 的 PDF。LoadDocument 一旦返回,你就可以立即释放、复用或覆写那个容器,因为组件不再引用它。这是默认行为,也是几乎所有代码的正确选择
Buffered = False 会把 @Data[Index] 直接传给 FPDF_LoadMemDocument64,PDFium 会在文档的整个生命周期里保留这个指针,而不是复制这些字节。这让加载过程零分配,但也让整个底层 TBytes 变成了一份被借用的资源。它必须保持存活且不被修改,直到 UnloadDocument 执行,或者 Active 变为 False。不只是那个窗口,是整个数组:一个动态数组是作为一个整体做引用计数的,让最后一个引用在你代码里任何地方消失,都会释放 PDFium 还在读取的那块内存。对它调用 Length 同样是致命的,因为一次重新分配可能会移动那块内存。在你自己暴露这种加载方式的 API 文档里,也应该像 Pascal 代码里其他任何"借用还是拥有"的边界一样,把这一点写清楚;这种失败模式和Delphi 中 FillChar 与结果字符串泄漏一文里描述的别名风险完全相同,都是一个看起来像是被拥有、实际却不是的缓冲区
type
TFrameSession = class
private
FFrame: TBytes; // owns the backing storage for as long as FPdf is loaded
FPdf: TPdf;
public
procedure OpenEmbedded(Offset, Size: Integer);
destructor Destroy; override;
end;
procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
// Buffered = False: FFrame must outlive the loaded document
FPdf.LoadDocument(FFrame, Offset, Size, False);
end;
destructor TFrameSession.Destroy;
begin
FPdf.UnloadDocument; // release the borrow first
FFrame := nil; // only now may the storage go
inherited;
end;
字节范围窗口不适用的场合
要对这条边界坦诚。字节范围重载假定容器已经完整地在内存里,而且 Count 是一个 Integer,所以单个窗口不能超过两吉字节。如果容器是磁盘上一个 6 GB 的归档,或者来自一个你无法回退的套接字,这个重载帮不上忙,而为了在里面寻址一个窗口就把整个东西读进 TBytes,本身就违背了这个重载存在的意义。这正是 FPDF_FILEACCESS 路径该出场的地方,按需流式加载那篇文章展示了如何把一个文件里偏移过的视图暴露成一个自定义文档源。同样地,如果嵌入的字节在被 PDFium 看到之前需要先经过转换——解压、解密、解包——那么真正的复制是无法避免的,对转换后的数组用 Buffered = True 才是诚实的答案。字节范围窗口只在一种形态下才有回报:连续的、未经修改的 PDF 字节,已经常驻内存,位于一个已知的偏移量处
如果你正在为一个查看器、一个预览面板,或者一条批量接收流水线评估这个方案,字节范围重载和流式加载器,是 PDFium Component 随文件、流和裸指针加载一起提供的几种加载策略中的两种。完整的 API、授权方式以及对 Delphi 和 C++Builder 各版本的支持,都记录在 PDFium Component 产品页上