一位低视力读者在默认对比度下看不清白页上的黑字,于是要求一个暗色模式。天真的答案是把渲染好的页面每个像素都反色。它一周就能发出去,第二天就坏掉:扫描的照片回来看着像胶片负片,读者的黄色荧光笔标记变成一片辨不清的蓝色污渍,还有人来问为什么打印出来是整片黑。这个功能确实值得做,也确实很容易只做对一半,而两种结局之间的差距只有一个念头:每一个颜色决策都属于渲染管线上某个特定的位置,而反色是用错了阶段的错误工具。这里的代码用的是 PDFium Component,一个面向 Delphi、C++Builder 和 Lazarus 的基于 PDFium 的查看器,它的渲染 API 把那些阶段分别暴露了出来
滤镜是呈现状态,绝不是文档状态
有一条规则能挡住这里最糟的一类缺陷:阅读模式改变的是位图如何生成或如何后处理,此外什么都不改。PDF 字节原封不动,每一种模式都能通过重新渲染撤销,而"保存"绝不会把带滤镜的外观写回文件。这听起来是废话,直到一位法务评审员在滤镜生效的状态下打印了一份合同并把反色版本归了档。到那一刻,"打印用的是文档自己的外观还是屏幕的外观"这个问题就值得在你的规格书里得到一个明确回答,而不是任由代码路径去碰巧决定。把滤镜设置放在查看器状态里,在渲染时施加,并让每一条导出路径都声明自己用的是哪一种外观
这条规则的回报有两份。可逆性是白送的,因为切换模式是从未改动的源重新渲染:既没有撤销栈要维护,一连串模式切换也无从让页面劣化。多窗口场景保持一致也是同一个原因。同一份文档的两个视图可以跑不同的模式,因为每个视图拥有自己的呈现状态,而文档对象保持共享
先渲染,后变换
受支持的做法是渲染后的位图处理:RenderPage 产出页面栅格,然后一趟变换去调整它。组件以就地位图操作的形式提供三种变换,InvertPdfBitmap、DuotonePdfBitmap 和 GrayscalePdfBitmap,这让模式切换成为一个干净的两段式函数:
function TViewerForm.RenderWithMode(W, H: Integer): TBitmap;
begin
Result := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
case FReadingMode of
rmInverted: InvertPdfBitmap(Result);
rmHighContrast: DuotonePdfBitmap(Result, clBlack, $0000C8FF); // 暗底,琥珀色文字
rmGrayscale: GrayscalePdfBitmap(Result);
end;
// rmNormal 直接落下去:文档保留自己的颜色
end;
这个设计带来两个推论。第一,变换代价与位图尺寸成正比,所以这份工作应当放在你缓存渲染结果的地方:对缓存的位图过滤一次,而不是每次绘制都过滤。第二,因为变换跑在成品栅格上,它对文字、矢量图、图像和注解外观一视同仁。而这种一视同仁,正是纯反色对照片做错了的地方。这也是为什么对文字密集的文档来说,双色调变换是更好的默认值,因为它把亮度映射到一条选定的由深到浅的色带上,而不是把色相取反;反色仍然作为一个显式选项留给想要它的读者。让字形边缘更锐利是另一根独立的杠杆。reNoSmoothText 渲染选项在渲染时关掉文字抗锯齿,与大倍率下的高对比度模式配合得很好
两种互相矛盾的灰度
渲染选项里有一个 reGrayscale,看起来像是绕过后处理这一步的捷径。它并不是同一个操作:
// 引擎层:在栅格化过程中施加灰度
GrayA := Pdf.RenderPage(0, 0, W, H, ro0, [reGrayscale]);
// 后处理:按彩色渲染,再转换成品位图
GrayB := Pdf.RenderPage(0, 0, W, H);
GrayscalePdfBitmap(GrayB);
引擎层的选项作用于图像内容的栅格输出,却够不到矢量填充和文字颜色,所以一个带彩色标题的页面回来时可能是灰色照片配上倔强的蓝色标题。而对成品位图调用 GrayscalePdfBitmap 会无条件地把一切都转换掉。当你想让图像去饱和、同时保留文字颜色作为一种信号时,那个渲染选项仍有它的位置——有些低视力读者就是特别偏好这样。但如果需求写的是"整页灰度",那么后处理才是满足它的那个版本。不论你走哪条路,都要把 RenderPage 的两种重载风格记在心上。函数形式返回的位图归调用方所有、必须由调用方释放,而一旦滤镜把在途渲染位图的数量成倍放大,这一点就要紧起来了
背景、选中标记,以及 PageColor 陷阱
并不是每一项舒适度调整都是变换。把白色页面背景换成一种暖色调,对畏光的读者来说往往单独就够了,而且它有专门的属性。这个属性带着一条把人绊倒的作用域规则:
// 只影响屏幕上的视图
PdfView.PageColor := $00D9EDF2; // 页面内容背后的暖纸色调
// RenderPage 的输出会忽略 PageColor;要显式传入颜色
Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [], $00D9EDF2);
PageColor 改变的是 TPdfView 显示的东西,而通过 RenderPage 产出的位图除非 Color 参数另有说法,否则保持默认的白色。症状很稳定:屏幕上显示着染了色的页面,用户一导出或一打印,输出就变回白的。把这条归进第一节里同一个导出策略决定
剩下的颜色属性定义的是叠加标记:HighlightColor 用于搜索命中,SelectionColor 用于用户的文本选中,ReadingWordColor 用于朗读词光标。它们每一个都必须在你提供的每一种滤镜下重新检查一遍。一个在白底上好用的琥珀色朗读光标,在反色之后就消失了;一片淡蓝色的选中,会溶进高对比度的背景里。要按模式维护多套叠加层配色,而不是一套全局的,并且有意去测那些组合。滤镜叠加文本转语音,对这个功能所服务的读者来说是常规配置,不是边角情况。叠加机制本身在无障碍阅读器一文中有讲
数字、验证,以及打印这个问题
WCAG 2.1 把这个功能变成了可以测量的东西。成功准则 1.4.3 要求正文文本的对比度达到 4.5:1,而 1.4.6 把增强对比度提到 7:1。用一个对比度分析器在实际渲染输出上跑一遍,抽查你的高对比度模式是否达到这些比值。图像上的文字和表单字段里的文字,正是即便正文达标、比值也会悄悄不合格的地方
打印值得单独做一个决定,而站得住脚的默认值是文档自己的外观,同时把"按显示效果打印"作为一个显式的用户选项提供出来。打印页在很多工作流里都是证据,比查看器作者通常预想的要多,而一份合同的反色打印件是一起带着法律味道的支持事故。还有一处配对对性能要紧:带滤镜的渲染会让每次模式切换的位图工作量翻倍,所以别在每条绘制消息上都施加一次变换。把过滤后的位图缓存起来,只在页面、缩放或模式真正变化时才重跑变换。让这件事变便宜的缓存策略住在渲染缓存与缩放性能一文里
有一件事该在你的界面里而不是代码里定下来:哪一种模式是正确的默认值。这没有唯一答案,所以把这一整套都提供出来,让读者自己挑。高对比度适合大多数文字密集的阅读,反色适合特别想要暗底亮字的读者,灰度削掉颜色噪音,而背景染色应付畏光。按用户持久化这个选择,启动时恢复它,并留一条一次按键就能回到普通模式的通路,因为一个掉进自己读不了的模式里的读者,需要一个快速的出口
这里用到的渲染选项、位图变换和视图颜色属性,都随 PDFium Component 一同发布,支持 Delphi、C++Builder 和 Lazarus/FPC,并附完整源码,以便审阅或扩展这些变换的实现