技术文章

Delphi中PDFium矩阵前乘与后乘:轴心旋转

PDF仿射矩阵采用ISO 32000-1 §8.3.3的行向量约定,一个点是从左侧乘以矩阵的:point' = point * M。在面向Delphi和C++Builder的PDFium Component中,这一个事实就定死了TPdfMatrix整个API的形状:Multiply是后乘(append),所以是M := M * Op;而PreMultiply是前乘(prepend),所以是M := Op * M

每一个经典的变换类bug,追根溯源都是这句话被反着记住了。在你的测试文件里旋转得整整齐齐的水印,到了客户的文件里就跑到页面外面一半。因为页面本身已经带了一个四分之一圈的旋转,缩略图就被转了两遍。在A4纸上偏移量恰到好处的图章,到了Letter纸上就跑偏了。这些都不是渲染层面的bug,它们都是乘法顺序上的bug,而一旦你能清楚说出每一次操作到底是在哪个空间里写的,它们就都能修好

决定规则的行向量约定

TPdfMatrix存储着规范里命名的那六个元素,并且完全按照格式定义的方式应用它们,所以推理要从变换本身开始。TPdfMatrix.TransformPoint计算的是x' = x*a + y*c + ey' = x*b + y*d + f,这正是ISO 32000-1 §8.3.4为cm操作符定义的六元素形式,用于把一个矩阵拼接到当前变换矩阵上。数对(a, b)是第一行,(c, d)是第二行,(e, f)是平移那一行。从OpenGL或者线性代数课上养成的列向量思维习惯,在这里会误导你,而且是悄无声息地误导你,因为一个顺序错了的矩阵依然是一个完全合法的矩阵。按行向量约定从左往右读一个复合表达式,应用顺序就自然而然地出来了:因为point * (M * Op)等于(point * M) * Op,一次后乘的操作作用在现有矩阵已经产生的坐标上,也就是页面空间;而一次前乘的操作在现有矩阵生效之前就先起作用,作用在对象自身的输入空间里

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Append order: each call acts on what the previous calls produced.
    M.Scale(0.5, 0.5);                   // M := M * S   half size
    M.Rotate(90);                        // M := M * R   clockwise, degrees
    M.Translate(300, 400);               // M := M * T   then move on the page

    Pt := M.TransformPoint(0, 0);        // x*a + y*c + e, x*b + y*d + f
  finally
    M.Free;
  end;
end;

TPdfMatrix.Rotate默认是顺时针方向、以角度为单位,当你的源数据用的是相反的符号约定时,可以用ACounterClockwiseAAngleInRadians参数。只读的af这几个属性,加上Handle属性,能把原始的FS_MATRIX还给你,这正是FPDFPageObj_SetMatrix所需要的。这个类没有对你隐藏这六个数字中的任何一个,这是刻意为之的:当一次变换出问题时,把af打印出来是你手头最快的诊断手段

为什么前乘一次平移需要用到线性部分?

因为一次前乘的位移是写在矩阵输入空间里的,它必须先经过当前的线性部分转换,才能并入平移那一行。所以TPdfMatrix.PreTranslate计算的是e := dx*a + dy*c + ef := dx*b + dy*d + f。后乘是简单的那个方向:TPdfMatrix.Translate是写在页面空间里的,那里什么都不需要转换,所以它只是把dx加到e上、把dy加到f上。任何人如果把PreTranslate"优化"成两次简单加法,就等于把这次位移里的旋转和缩放全部删掉了

M := TPdfMatrix.Create;
try
  M.Rotate(90);              // a=0, b=-1, c=1, d=0

  M.Translate(10, 0);        // append: e := e + 10
                             // -> 10 points to the right on the page

  M.Reset;
  M.Rotate(90);
  M.PreTranslate(10, 0);     // prepend: e := 10*a + 0*c + e  (unchanged)
                             //          f := 10*b + 0*d + f  (f - 10)
                             // -> 10 points along the stamp own x axis,
                             //    which after the turn points down the page
finally
  M.Free;
end;

同样的不对称贯穿在这一对缩放函数里,在你凌晨三点调试它们之前,先弄清楚每一个动的是哪些元素是值得的。TPdfMatrix.PreScale做的是行的乘法,把ab乘以scaleX、把cd乘以scaleY,平移部分保持不动,因为那次位移已经在它下游发生过了。后乘的TPdfMatrix.Scale做的则是列的乘法,把ace乘以scaleX、把bdf乘以scaleY,所以现有的偏移量会跟着其他一切一起被缩放。这两者都是跳过了通用六元素乘法的单一用途路径,而且两者都精确地保持了通用形式的组合语义

轴心旋转中,那两次平移分别放在哪里?

是围绕着这次操作本身,而不是围绕整个矩阵,而且顺序也是固定的。TPdfMatrix.RotateAt依次后乘Translate(-pivot)、然后是旋转、然后是Translate(+pivot),在行向量约定下,这组合起来就是Translate(-pivot) * Op * Translate(pivot)。正是这个顺序,让轴心点在这次新操作下保持不动,同时依然让现有矩阵先产生它的坐标、再往下传递。如果把这一对反过来写——就像在一个列向量的库里那样才是对的——对象就会绕着原点公转,而不是原地自转,这正是一个居中的水印最终会跑到裁剪框外面去的原因

procedure RotateStampAboutPageCenter(AObj: FPDF_PAGEOBJECT;
  const AAngleDegrees, APageWidth, APageHeight: Single);
var
  M: TPdfMatrix;
  Raw: FS_MATRIX;
begin
  if not FPDFPageObj_GetMatrix(AObj, Raw) then
    raise Exception.Create('Page object carries no matrix');
  M := TPdfMatrix.Create(Raw);
  try
    // Appends Translate(-pivot) * Rotate * Translate(+pivot) in one call.
    M.RotateAt(AAngleDegrees, APageWidth / 2, APageHeight / 2);
    Raw := M.Handle;
    FPDFPageObj_SetMatrix(AObj, Raw);
  finally
    M.Free;
  end;
end;

ScaleAtSkewAtHorizontalFlipAtVerticalFlipAtCentralFlipAt背后是同样的组合方式,所以一旦你信得过旋转这个模式,就可以放心地把它用在其余这些函数上。TPdfMatrix.CentralFlip值得单独提一句:它把全部六个元素都取负,让你不做任何三角函数运算就能得到一次180度的翻转,这意味着不会出现一个本该恰好是零的值去求cos,也不会在循环中反复应用时累积漂移误差。如果你放置的是重复出现的标记,而不是旋转一个已有对象,具体放置机制在用Form XObjects实现可复用的页面图章一文中有介绍,这里讲的矩阵运算正好是它的下层基础

TryDecompose能告诉你一个矩阵的哪些信息?

TPdfMatrix.TryDecompose按照"先缩放后旋转"的约定,报告平移、缩放、旋转、切变、行列式和一个反射标志,而且它报告得足够诚实,可以用来做决策,而不只是用来打日志。ScaleX来自第一行的长度,也就是Sqrt(a*a + b*b),所以它永远是正数。ScaleY则等于Determinant / ScaleX,这就使得它是带符号的。旋转角度来自ArcTan2(-b, a),以角度为单位;切变则来自两行的点积、按两个缩放值归一化后得到

ScaleY上的这个符号,正是人们容易删掉的部分,而删掉它是一个实实在在的bug,不是无关紧要的外观问题。一个负的行列式意味着这个矩阵包含一次反射。为了让数字看起来更整齐而强行把两个缩放系数都变成正数,你就把这次反射信息给扔掉了,于是一个从这份分解结果重建出来的矩阵会带着镜像回来:文字变得左右颠倒,一页扫描件被翻转,一个导入的logo朝向错误的方向。IsReflected这个字段存在的意义,就是让你永远不必自己去推断这一点。这也正是那道能防止经典"重复旋转"问题的检查——代码给一个本来已经带了旋转的页面又叠加了一次展示层面的旋转;这个问题在查看器一侧的版本,在缩略图适配、缩放与重复旋转一文中有详细分析

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX is always positive; D.ScaleY carries the determinant sign.
    if D.IsReflected then
      Log('mirrored, ScaleY = %.3f', [D.ScaleY]);

    if Abs(D.RotationDegrees) > 0.5 then
      SkipDisplayRotation;      // the object already carries its own turn
  end
  else
    UseIdentityFallback;        // near-singular or non-finite: no answer
end;

把一个矩形不靠猜测地装进另一个矩形

TPdfMatrix.TryCreateRectMapping会替你构建从源矩形到目标矩形的映射矩阵,接受一个TPdfMatrixFitMode参数,取值为pmfStretchpmfContainpmfCover。它首先会把两个矩形都归一化,因为PDF矩形并不保证左边坐标一定小于右边、下边坐标一定小于上边,然后分别推导出独立的X和Y缩放系数:pmfStretch让两者各自独立,pmfContain取较小的那个并把留白居中,pmfCover取较大的那个并把裁掉的部分居中。配套的MapRectToRect会把同样的映射后乘到一个已有矩阵上,而NewRectMappingTry形式会返回False的地方会抛出EPdfMatrixError。这正是多合一拼版与页面重排一文中每一次单元格放置背后的基础运算,那篇文章里每一个源页面都要落进一个计算出来的单元格中,而不用你每次排版都重新推导一遍算术过程

退化矩阵与诚实的失败路径

有限的输入并不保证一定能得到有限的结果,所以拟合代码用Double类型进行计算,然后在把结果收窄为Single类型的候选值之后,会再次检查它是否有限,才会把结果发布出去;一个包含无穷大的映射结果绝不会被当作有效结果原样交还。同样的规范也贯穿在求逆运算中。TPdfMatrix.TryGetInverse用一个相对阈值来拒绝某个矩阵,它拿行列式和"epsilon乘以最大线性元素的平方"做比较,而不是和一个固定常数比较,这正是让这项检查无论你的单位是磅还是微米都同样有意义的关键。TryDecompose也是用同样的方式来判定失败的,当第一行的长度或者推导出的ScaleY低到等于或低于epsilon时就会拒绝

根据调用点的具体情况来选择合适的失败风格,而不是出于习惯把所有东西都包进try-except里。TryInvertTryGetInverseTryInverseTransformPointTryTransformBoundsTryCreateRectMapping返回False,并保持它们的目标参数不变,这适合命中测试和逐对象循环这类场景,在这些场景里一个退化的对象应该被跳过,而不是导致致命错误。InvertInverseCopyInverseTransformPointMapRectToRectTransformBounds则会抛出EPdfMatrixError,这适合初始化阶段的代码,在那里一个奇异矩阵就意味着调用方哪里算错了。对于批量处理的工作,TransformPointsTransformRects只精确分配一次结果数组,TransformPointsInPlaceTransformRectsInPlace复用你自己的存储空间,而TryTransformBounds是在一次遍历中直接累积出包围盒,而不是先把变换后的点都物化出来再处理

这些都不是什么高深的数学。这是同一套约定,被一以贯之地应用,而且API的命名方式让这套约定在调用点上一目了然:Multiply和那些普通动词形式的方法做后乘,Pre这一族做前乘,At这一族则用一对轴心平移把操作包裹起来。在你构建的任何一个复合变换旁边,都把顺序写进注释里,因为今天读起来正确的代码,正是六个月后会被人读反的代码。完整的TPdfMatrix参考,连同这些变换所服务的页面对象与渲染API,都在面向Delphi和C++Builder的PDFium Component中提供