บทความเทคนิค

Matrix Prepend กับ Append ของ PDFium ใน Delphi: การหมุนรอบจุด

Affine matrix ของ PDF ใช้ธรรมเนียม row-vector ของ ISO 32000-1 §8.3.3 ที่จุดหนึ่งคูณ matrix จากด้านซ้าย: point' = point * M ใน PDFium Component สำหรับ Delphi และ C++Builder ข้อเท็จจริงข้อเดียวนั้นกำหนดพื้นผิว API ทั้งหมดของ TPdfMatrix: Multiply ทำ append คือ M := M * Op ในขณะที่ PreMultiply ทำ prepend คือ M := Op * M

บั๊ก transform แบบคลาสสิกทุกตัวย้อนกลับไปที่ประโยคนั้นถูกจดจำกลับด้าน watermark ที่หมุนได้เรียบร้อยในไฟล์ทดสอบของคุณแต่ลงเอยที่ครึ่งหนึ่งอยู่นอกหน้าในไฟล์ของลูกค้า thumbnail ที่ออกมาหมุนซ้ำสองรอบเพราะหน้ากระดาษมีการหมุนหนึ่งในสี่รอบติดมาอยู่แล้ว ตราประทับที่ offset สมบูรณ์แบบบน A4 แต่เพี้ยนบน Letter ไม่มีข้อไหนเป็นบั๊กเรื่องการ render เลย ทั้งหมดเป็นบั๊กเรื่องลำดับการคูณ และทั้งหมดแก้ได้ทันทีที่คุณพูดออกมาดัง ๆ ได้ว่าแต่ละ operation เขียนอยู่ในพื้นที่ไหน

ธรรมเนียม row-vector ที่กำหนดกฎ

TPdfMatrix เก็บสมาชิกทั้งหกตัวที่สเปกตั้งชื่อไว้และใช้มันตามที่รูปแบบไฟล์นิยามไว้พอดี ดังนั้น transform เองจึงเป็นจุดที่การให้เหตุผลเริ่มต้น TPdfMatrix.TransformPoint คำนวณ x' = x*a + y*c + e และ y' = x*b + y*d + f ซึ่งเป็นรูปแบบหกสมาชิกที่ ISO 32000-1 §8.3.4 นิยามไว้สำหรับ operator cm ที่ต่อ matrix เข้ากับ current transformation matrix คู่ (a, b) คือแถวแรก (c, d) คือแถวที่สอง และ (e, f) คือแถว translation นิสัยแบบ column-vector ที่ติดมาจาก OpenGL หรือจากวิชาพีชคณิตเชิงเส้นจะทำให้คุณเข้าใจผิดตรงนี้ และมันจะทำให้คุณเข้าใจผิดอย่างเงียบ ๆ ด้วย เพราะ matrix ที่ลำดับผิดก็ยังคงเป็น matrix ที่ถูกต้องสมบูรณ์แบบ อ่านองค์ประกอบในธรรมเนียม row จากซ้ายไปขวาแล้วลำดับการใช้งานก็ตกลงมาให้เองฟรี ๆ เพราะ point * (M * Op) เท่ากับ (point * M) * Op operation ที่ append ทำงานกับพิกัดที่ matrix ที่มีอยู่แล้วผลิตออกมาแล้ว คือทำงานใน page space ในขณะที่ operation ที่ prepend ทำงานก่อนที่ matrix ที่มีอยู่จะรัน คือใน input space ของอ็อบเจกต์เอง

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 ค่าเริ่มต้นเป็นตามเข็มนาฬิกาและเป็นองศา โดยมี ACounterClockwise กับ AAngleInRadians ให้ใช้เมื่อข้อมูลต้นทางของคุณกำหนดเครื่องหมายตรงข้าม property a ถึง f แบบอ่านอย่างเดียวและ property Handle ให้ FS_MATRIX ดิบกลับมา ซึ่งเป็นสิ่งที่ FPDFPageObj_SetMatrix ต้องการ ไม่มีอะไรในคลาสนี้ซ่อนตัวเลขทั้งหกจากคุณเลย และนั่นตั้งใจให้เป็นแบบนั้น: เมื่อ transform ทำงานผิดปกติ การพิมพ์ a ถึง f ออกมาคือการวินิจฉัยที่เร็วที่สุดที่คุณมี

ทำไมการ prepend translation ถึงต้องใช้ส่วน linear

เพราะการเลื่อนแบบ prepend ถูกเขียนอยู่ใน input space ของ matrix และมันต้องถูกนำผ่าน linear part ปัจจุบันก่อนที่จะเข้าร่วมกับแถว translation ได้ TPdfMatrix.PreTranslate จึงคำนวณ e := dx*a + dy*c + e และ f := dx*b + dy*d + f การ append เป็นทิศทางที่ง่ายกว่า: TPdfMatrix.Translate เขียนอยู่ใน page space ที่ไม่มีอะไรต้องแปลงเลย ดังนั้นมันแค่บวก 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;

ความไม่สมมาตรแบบเดียวกันนี้วิ่งผ่านคู่ scale ด้วย และควรค่าแก่การรู้ไว้ว่าแต่ละตัวแตะสมาชิกไหนก่อนที่คุณจะไป debug ตัวหนึ่งตอนตีสาม TPdfMatrix.PreScale คูณแถว ขยาย a กับ b ด้วย scaleX และ c กับ d ด้วย scaleY และปล่อย translation ไว้เฉย ๆ เพราะการเลื่อนเกิดขึ้นที่ปลายทางไปแล้ว TPdfMatrix.Scale แบบ append คูณคอลัมน์แทน โดยเอา a, c, e คูณด้วย scaleX และ b, d, f คูณด้วย scaleY ดังนั้น offset ที่มีอยู่แล้วจึงขยายไปพร้อมกับทุกอย่างอื่นด้วย ทั้งสองเป็น path จุดประสงค์เดียวที่ข้ามผลคูณหกสมาชิกทั่วไปไป และทั้งสองรักษา semantics การประกอบของรูปแบบทั่วไปไว้ทุกประการ

Translation ทั้งสองไปอยู่ที่ไหนในการหมุนรอบจุด

รอบ operation ไม่ใช่รอบทั้ง matrix และตามลำดับนั้น TPdfMatrix.RotateAt ทำ append Translate(-pivot) แล้วการหมุน แล้ว Translate(+pivot) ซึ่งภายใต้ธรรมเนียม row-vector ประกอบกันเป็น Translate(-pivot) * Op * Translate(pivot) ลำดับนั้นคือสิ่งที่ทำให้ pivot คงที่ภายใต้ operation ใหม่ ในขณะที่ยังให้ matrix ที่มีอยู่แล้วผลิตพิกัดของมันก่อนแล้วส่งต่อ เขียนคู่นั้นกลับด้าน ตามที่จะถูกต้องในไลบรารีแบบ column-vector แล้วอ็อบเจกต์จะโคจรรอบจุดกำเนิดแทนที่จะหมุนอยู่กับที่ ซึ่งเป็นสาเหตุที่ watermark ที่จัดกึ่งกลางไว้ลงเอยอยู่นอก crop box พอดี

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;

การประกอบแบบเดียวกันนี้รองรับ ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt และ CentralFlipAt ด้วย ดังนั้นเมื่อคุณเชื่อในรูปแบบนี้สำหรับการหมุนแล้ว คุณก็เชื่อได้สำหรับที่เหลือด้วย TPdfMatrix.CentralFlip ควรค่าแก่การแยกออกมาพูดถึง: มันปฏิเสธสมาชิกทั้งหกตัวเพื่อให้คุณได้การหมุน 180 องศาโดยไม่ต้องใช้ตรีโกณมิติเลย ซึ่งหมายความว่าไม่มี cos ของค่าที่ควรจะเป็นศูนย์พอดี และไม่มีการเลื่อนสะสมเมื่อคุณใช้มันในลูป ถ้าคุณกำลังวางตราซ้ำ ๆ แทนที่จะหมุนอันเดียว กลไกของการวางเองครอบคลุมไว้ใน ตราประทับหน้าที่นำกลับมาใช้ใหม่ได้ด้วย Form XObject และงาน matrix ที่นี่วางอยู่บนมันโดยตรง

TryDecompose บอกอะไรเกี่ยวกับ matrix หนึ่ง

TPdfMatrix.TryDecompose รายงาน translation, scale, rotation, shear, determinant และ flag การสะท้อนภายใต้ธรรมเนียม scale-แล้ว-rotation และมันรายงานสิ่งเหล่านั้นอย่างซื่อตรงพอที่จะใช้ตัดสินใจได้ ไม่ใช่แค่เพื่อ log ScaleX มาจากความยาวของแถวแรก คือ Sqrt(a*a + b*b) ดังนั้นมันจึงเป็นบวกเสมอ ScaleY จากนั้นคือ Determinant / ScaleX ซึ่งทำให้มันมีเครื่องหมาย Rotation มาจาก ArcTan2(-b, a) เป็นองศา และ shear มาจากผลคูณจุดของทั้งสองแถว normalize ด้วยทั้งสอง scale

เครื่องหมายบน ScaleY นั้นเป็นส่วนที่คนมักลบทิ้ง และการลบมันทิ้งเป็นบั๊กจริง ไม่ใช่แค่เรื่องความสวยงาม determinant ที่เป็นลบหมายความว่า matrix มีการสะท้อนอยู่ในนั้น บังคับให้ scale factor ทั้งสองเป็นบวกเพื่อให้ตัวเลขดูเรียบร้อยขึ้น แล้วคุณก็เพิ่งทิ้งการสะท้อนไปแล้ว ดังนั้น matrix ที่สร้างขึ้นใหม่จาก decomposition จะกลับมาแบบสะท้อนกลับด้าน: ข้อความอ่านกลับด้าน หน้าที่สแกนมาพลิกกลับ โลโก้ที่นำเข้ามาหันหน้าผิดทิศทาง ฟิลด์ IsReflected มีอยู่เพื่อไม่ให้คุณต้องอนุมานมันเลย นี่ยังเป็นการตรวจสอบที่ป้องกันการหมุนซ้ำสองครั้งแบบคลาสสิก ที่โค้ดเพิ่มการหมุนสำหรับแสดงผลเข้าไปในหน้าที่มีการหมุนติดมาอยู่แล้ว เวอร์ชันฝั่ง viewer ของปัญหานั้นถูกอธิบายไว้ใน การ fit thumbnail, zoom และการหมุนซ้ำสอง

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 สร้าง matrix จากต้นทางไปยังปลายทางให้คุณ และรับ TPdfMatrixFitMode เป็น pmfStretch, pmfContain หรือ pmfCover มัน normalize สี่เหลี่ยมทั้งสองก่อน เพราะสี่เหลี่ยมของ PDF ไม่จำเป็นต้องมาถึงโดยที่ซ้ายอยู่ใต้ขวาหรือล่างอยู่ใต้บน แล้วจึงคำนวณ scale X และ Y แบบอิสระ: pmfStretch คงมันเป็นอิสระจากกัน pmfContain เอาตัวที่เล็กกว่าและจัดกึ่งกลาง letterbox pmfCover เอาตัวที่ใหญ่กว่าและจัดกึ่งกลางการ crop MapRectToRect ที่มาคู่กันจะ append การ mapping เดียวกันนี้เข้ากับ matrix ที่มีอยู่แล้ว และ NewRectMapping raise EPdfMatrixError ตรงจุดที่รูปแบบ Try คืนค่า False นี่คือ primitive เบื้องหลังการวางแต่ละ cell ใน N-up imposition และการเรียงหน้าใหม่ ที่แต่ละหน้าต้นทางต้องลงเอยภายใน cell ที่คำนวณไว้โดยที่คุณไม่ต้องหาสูตรเลขคณิตใหม่ต่อ layout

Matrix ที่เสื่อมสภาพ และ path ความล้มเหลวที่ซื่อตรง

Input ที่จำกัดไม่รับประกันผลลัพธ์ที่จำกัดด้วย ดังนั้นโค้ดการ fit จึงคำนวณเป็น Double แล้วตรวจสอบตัวเลือก Single ที่แคบลงมาอีกครั้งว่าจำกัดหรือไม่ก่อนเผยแพร่มัน mapping ที่มีค่าอนันต์อยู่ในนั้นจะไม่มีวันถูกส่งกลับราวกับว่ามันถูกต้อง วินัยเดียวกันนี้ควบคุมการ inversion ด้วย TPdfMatrix.TryGetInverse ปฏิเสธ matrix ด้วยเกณฑ์แบบสัมพัทธ์ เปรียบเทียบ determinant กับ epsilon คูณกำลังสองของสมาชิก linear ที่ใหญ่ที่สุด แทนที่จะเทียบกับค่าคงที่ตายตัว ซึ่งเป็นสิ่งที่ทำให้การทดสอบมีความหมายไม่ว่าหน่วยของคุณจะเป็น point หรือ micrometre TryDecompose ก็ล้มเลิกในแบบเดียวกัน ปฏิเสธเมื่อความยาวแถวแรกหรือ ScaleY ที่ได้มาตกอยู่ที่หรือต่ำกว่า epsilon

เลือกสไตล์ความล้มเหลวที่เหมาะกับจุดเรียกใช้แทนที่จะห่อทุกอย่างด้วย try-except ตามความเคยชิน TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds และ TryCreateRectMapping คืนค่า False และปล่อยเป้าหมายของมันไว้ไม่แตะต้อง ซึ่งเหมาะกับ hit-testing และลูปต่ออ็อบเจกต์ที่อ็อบเจกต์ที่เสื่อมสภาพควรถูกข้ามไป ไม่ใช่ร้ายแรงถึงตาย Invert, InverseCopy, InverseTransformPoint, MapRectToRect และ TransformBounds raise EPdfMatrixError แทน ซึ่งเหมาะกับโค้ดตั้งค่าที่ matrix เอกฐานหมายความว่าผู้เรียกคำนวณอะไรผิดพลาดไป สำหรับงานแบบ batch TransformPoints กับ TransformRects จัดสรร result array ของมันแค่ครั้งเดียวพอดี TransformPointsInPlace กับ TransformRectsInPlace นำ storage ของคุณกลับมาใช้ใหม่ และ TryTransformBounds สะสม bounding box ในรอบเดียวแทนที่จะสร้างจุดที่ transform แล้วขึ้นมาก่อน

ไม่มีอะไรในนี้เป็นคณิตศาสตร์แปลกประหลาดเลย มันคือธรรมเนียมเดียวที่ถูกใช้อย่างสม่ำเสมอ พร้อม API ที่ตั้งชื่อให้ธรรมเนียมนั้นมองเห็นได้ที่จุดเรียกใช้: Multiply กับคำกริยาธรรมดา append ตระกูล Pre prepend ตระกูล At ล้อม operation ด้วยคู่ pivot ของมัน เขียนลำดับไว้ใน comment ข้าง ๆ องค์ประกอบใดก็ตามที่คุณสร้าง เพราะโค้ดที่อ่านถูกต้องวันนี้คือโค้ดที่มีคนกลับด้านมันในอีกหกเดือนข้างหน้า เอกสารอ้างอิง TPdfMatrix แบบเต็ม พร้อมกับ API ของ page-object และการ render ที่ transform เหล่านี้ป้อนเข้าไป อยู่กับ PDFium Component สำหรับ Delphi และ C++Builder