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

การเรียกซ้ำของ Form XObject: การตรวจจับวงจรของ PDFlibPas ใน Delphi

PDFlibPas แก้การเรียก Form XObject แบบเรียกซ้ำในตัวเอง (recursive) ใน content stream ของ PDF ใน Delphi ด้วยการติดตาม call chain ที่กำลังทำงานอยู่ ไม่ใช่ชุด visited ทั่วทั้งเอกสาร ดังนั้น TPDFlib.EnumPageContentStatesEx จึงสามารถเดินผ่าน Form เดียวกันที่ถูกเรียกหลายครั้งบนหนึ่งหน้าได้ โดยไม่เข้าใจผิดว่าการใช้ซ้ำที่ถูกต้องเป็นวงจร Form XObject แบบตราประทับในเทมเพลตใบแจ้งหนี้เป็นกรณีทั่วไป object เดียวกันถูกเรียกจาก header, footer และชั้นลายน้ำบนหน้าเดียวกัน และมีแค่ call chain ที่วนกลับมาที่ตัวเองเท่านั้นที่เป็นวงจรจริง

ISO 32000-1 §8.10 นิยาม Form XObject เป็น content stream ที่สมบูรณ์ในตัวเองซึ่งหน้าหนึ่ง หรือ Form อื่น เรียกใช้ด้วย operator Do พร้อมระบบพิกัดของตัวเองใน /Matrix, ขอบเขต clipping ในระบบพิกัดนั้นใน /BBox และอาจมี resource dictionary ของตัวเองก็ได้ ไม่มีอะไรในสเปคที่จำกัดว่า Form หนึ่งถูกเรียกได้กี่ครั้ง หรือ Form สามารถเรียกกันเองลึกแค่ไหน ดังนั้น parser ที่เป็นไปตามมาตรฐานต้องยอมรับการใช้ซ้ำและการซ้อนที่ถูกต้อง ในขณะที่ยังคงป้องกันตัวเองจากการจัดวางแบบเดียวที่สเปคห้ามจริงๆ คือ Form ที่ content stream ของมัน ไม่ว่าโดยตรงหรือทางอ้อม เรียกตัวเอง PDFlibPas รายงานความแตกต่างนั้นผ่านค่า TPDFlibContentFormTraversalStatus ที่ผูกกับ snapshot Do ทุกตัว โดยเฉพาะ ftsEnumerated สำหรับการเดินลงสำเร็จ และ ftsCycle สำหรับกรณีเดียวที่เป็นวงวนจริงๆ

ทำไมการใช้ Form XObject เดียวกันซ้ำถึงไม่กระตุ้นวงจรปลอม

การอ้างอิง Form XObject ที่ซ้ำกันไม่ใช่หลักฐานว่ามีอะไรผิดพลาดในตัวมันเอง ISO 32000-1 อนุญาตให้ object Form เดียวกันถูกเรียกจากที่ไหนก็ได้ในเอกสารมากเท่าที่ผู้เขียนต้องการ ซึ่งเป็นวิธีพอดีที่ตราประทับโลโก้, เทมเพลตหัวจดหมาย หรือ footer หมายเลขหน้าถูกใช้ซ้ำข้ามหน้าโดยไม่ต้องทำซ้ำ content stream ของมันหลายรอบ การป้องกันแบบไร้เดียงสาต่อการเรียกซ้ำที่ไม่มีที่สิ้นสุดคือชุด visited ชุดเดียว key ด้วยหมายเลข object ครั้งแรกที่ตัวเดินผ่านเห็น Form object 12 มันจะทำเครื่องหมาย 12 ว่าเห็นแล้วและปฏิเสธที่จะเข้าไปในมันอีกที่ไหนในต้นไม้ วิธีนั้นพังทันทีที่ตราประทับเดียวกันปรากฏในสองมุมที่ไม่เกี่ยวข้องกันของหน้าเดียว เพราะการเรียกครั้งที่สองที่ถูกต้องโดยสิ้นเชิงมาถึงหลังจากที่หมายเลข object ถูกทำเครื่องหมายว่าเห็นแล้ว และถูกปฏิเสธราวกับว่ามันเป็นวงวน

PDFlibPas หลีกเลี่ยงผลบวกลวงนั้นด้วยการจำกัดขอบเขตการตรวจจับวงจรไว้แค่ call chain ปัจจุบัน แทนที่จะเป็นทั้งเอกสาร EnumPageContentStatesEx push สตรีม Form ที่ resolve แล้วเข้า call chain ที่กำลังทำงานอยู่ทันทีก่อนที่จะลงไปในมัน แล้ว pop entry เดียวกันนั้นออกอีกครั้งทันทีที่การลงไปนั้นกลับมา ไม่ว่าจะสำเร็จหรือไม่ก็ตาม การเรียกพี่น้องของสตรีมเดียวกันเริ่มหลังจากที่ตัวแรกถูก pop ออกไปแล้วเท่านั้น ดังนั้น call chain จึงว่างเปล่าจากสตรีมนั้นเมื่อการเรียกพี่น้องตรวจสอบมัน และตัวเดินผ่านก็แจงรายการมันเหมือนกับ Form อื่นใดทุกประการ วงจรที่แท้จริงดูต่างออกไปบน chain เดียวกันนั้น Form A เรียก Form B, B ยังคงเปิดอยู่บน chain เมื่อเนื้อหาของมันเองเรียกกลับเข้า A และ A ยังคงนั่งอยู่บน chain จากการเรียกภายนอกที่ยังไม่คืนกลับ นั่นเป็นรูปร่างเดียวที่ ftsCycle รายงาน คือสตรีม Form ที่ยังคงเปิดอยู่ที่ไหนสักแห่งก่อนหน้าบน call chain ปัจจุบัน ไม่ใช่แค่มีอยู่ที่อื่นบนหน้าเฉยๆ

การเรียกซ้ำของ Form XObject ลึกได้แค่ไหนก่อนที่ PDFlibPas จะหยุดมัน

การตรวจจับวงจรและการจำกัดความลึกแก้ปัญหาที่ต่างกันสองอย่าง และ PDFlibPas เก็บมันไว้เป็นผลลัพธ์ TPDFlibContentFormTraversalStatus ที่ต่างกันสองแบบด้วยเหตุผลนั้นพอดี chain ของ Form ที่ต่างกันยี่สิบตัว แต่ละตัวเรียกตัวถัดไปและไม่มีตัวไหนซ้ำเลย ไม่ใช่วงจรตามนิยามใดๆ เลย การตรวจสอบ active-chain ไม่เคยพบสตรีมที่ซ้ำกัน แต่การซ้อนที่ซื่อสัตย์ยี่สิบระดับก็ยังคงเป็นการ parse, การ concatenate matrix และการ resolve resource ยี่สิบระดับ ที่ PDF ที่ผิดรูปแบบหรือประสงค์ร้ายสามารถผลักให้สูงขึ้นไปเรื่อยๆ ได้ถ้าไม่มีอะไรหยุดมัน EnumPageContentStatesEx รับพารามิเตอร์ MaxFormDepth ด้วยเหตุผลนี้พอดี และ clamp ค่าใดก็ตามที่ส่งเข้ามาให้สูงสุดที่ 64 ไม่ว่าผู้เรียกจะขอเท่าไหร่ก็ตาม ความลึกที่เป็นศูนย์เป็นกรณีพิเศษที่ควรรู้ไว้ด้วยตัวมันเอง มันปิดการเรียกซ้ำของ Form ทั้งหมด และสร้างพฤติกรรมแบบแบน-เฉพาะหน้า ของ method EnumPageContentStates รุ่นเก่าซ้ำ ซึ่งเป็นเหตุผลที่ทุก snapshot Do ในโหมดนั้นรายงาน ftsNotRequested แทนที่จะพยายามทำอะไร

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

Sub-Tracker หนึ่งตัวต่อการเรียกหนึ่งครั้ง: การแยก Graphics State

การลงไปใน Form XObject แต่ละครั้งได้ graphics-state tracker ของตัวเอง แทนที่จะใช้ตัวที่กำลังเดินผ่านหน้าอยู่แล้วร่วมกัน เพราะ content stream ของ Form ถูกกำหนดให้ทิ้ง graphics state ไว้เหมือนที่มันพบเป๊ะ และ PDFlibPas สมมติไม่ได้ว่าทุก PDF ที่มันเปิดจะเคารพข้อกำหนดนั้นจริงๆ tracker ลูกเริ่มจาก snapshot ของ CTM, สถานะสี และพารามิเตอร์ข้อความใดก็ตามที่ทำงานอยู่ที่คำสั่ง Do ที่เรียกมัน แล้วรีเซ็ต save-and-restore stack ของตัวเองและการติดตาม current-path ของตัวเองให้ว่างเปล่าก่อนที่จะรันคำสั่งใดๆ ของ Form q ที่ไม่สมดุลโดยไม่มี Q ที่ตรงกันภายใน Form ที่ไม่ระมัดระวังหรือเสียหาย ไม่ใช่เรื่องหายากที่จะพบใน PDF ที่ผลิตด้วยเครื่องมือรุ่นเก่า จะถูกจำกัดไว้ภายใน tracker ของการเรียกครั้งนั้นเท่านั้น และไม่เคยรั่วไหลเข้า tracker ของหน้าหรือเข้าการเรียกพี่น้องของตราประทับเดียวกันที่นั่งอยู่หนึ่งบรรทัดถัดมาใน content stream

/Matrix ของ Form ประกอบกับ CTM ที่มีผลอยู่ที่ Do แบบเดียวกับที่ operator cm ทำ คูณทางซ้ายกับ transform ปัจจุบัน แทนที่จะแทนที่มัน และ PDFlibPas จงใจใช้ code path เดียวกันนั้นซ้ำ แทนที่จะดูแลสูตรที่สอง เพราะ implementation อิสระสองตัวของพีชคณิต matrix เดียวกันเป็นความซ้ำซ้อนประเภทพอดีที่เลื่อนไหลออกจากกันอย่างเงียบๆ หลังจากการประกอบสเกล, หมุน และเฉือนไม่กี่รอบ /BBox จากนั้น clip ในพื้นที่พิกัดของ Form เองหลังจาก matrix ถูกใช้ไปแล้ว และมุมทั้งสี่ของกล่องนั้นถูกแปลงแยกกัน แทนที่จะแค่มุมตรงข้าม เพราะ Form ที่หมุนหรือเฉือนสามารถรายงาน bounding box ที่พลาดเนื้อหาจริงที่นั่งอยู่ในสิ่งที่เคยเป็นมุมสุดขั้วก่อนที่ transform จะย้ายมันไปที่อื่น การขยาย loop จากตัวอย่างก่อนหน้าเหนือ array States เดียวกันอ่านฟิลด์เหล่านั้นได้โดยตรง

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

Form สองตัวที่มีชื่อ Resource เดียวกันแชร์ฟอนต์เดียวกันหรือไม่

ไม่ ชื่อ resource อย่าง /F1 มีความหมายแค่สัมพัทธ์กับ resource dictionary ที่ทำงานอยู่ ณ จุดที่มันถูกใช้เท่านั้น และ Form XObject สองตัวที่ต่างกันสามารถนิยามฟอนต์สองตัวที่ต่างกันโดยสิ้นเชิงภายใต้ชื่อเดียวกันนั้นได้อย่างอิสระ PDFlibPas แก้ปัญหานี้ด้วยการติดตาม resource scope ควบคู่ไปกับทุกชื่อ resource เมื่อ Form พก dictionary /Resources ของตัวเอง dictionary นั้นกลายเป็น resource scope ที่สมบูรณ์สำหรับทุกอย่างข้างในมัน โดยไม่มี fallback ต่อ key ไปยัง dictionary ของหน้าหรือผู้เรียกสำหรับสิ่งที่ dictionary ของ Form เองบังเอิญละเว้น มีแค่ Form ที่ไม่มี key /Resources เลยเท่านั้น ซึ่งเป็นรูปแบบที่ generator PDF รุ่นเก่าบางตัวยังคงสร้าง ที่สืบทอด dictionary ของผู้เรียกทั้งหมด และนั่นเป็นข้อยกเว้นด้านความเข้ากันได้ที่จงใจ ไม่ใช่กฎทั่วไปที่ควรพึ่งพาในเอาต์พุตใหม่ ตัวตนของฟอนต์ใน snapshot TPDFlibContentGraphicsState จึงเป็นคู่ของ FontResource และ FontResourceScope ไม่ใช่แค่ชื่อเพียงอย่างเดียว โดยมี FontObjectNumber ให้ใช้ยืนยันว่า /F1 ที่กำหนดนั้น resolve ไปยัง indirect object ตัวไหนใน scope นั้นโดยเฉพาะ

การจำกัดขอบเขตเดียวกันนี้ใช้กับทุก resource ที่มีชื่อที่ Form สามารถพกได้ รวมถึง entry ExtGState และ entry XObject ที่ซ้อนกัน เพราะกลไกการ resolve ข้างใต้ไม่ได้ปฏิบัติต่อฟอนต์เป็นกรณีพิเศษเลย กรณีฟอนต์แค่บังเอิญสำคัญที่สุดเท่านั้น เพราะตัวตนฟอนต์ที่ไม่ตรงกันสร้าง glyph ที่ผิดอย่างเงียบๆ แทนที่จะเป็นความล้มเหลวที่ชัดเจน โค้ดการดึงข้อมูลที่จัดกลุ่ม text run แค่ตามชื่อฟอนต์เพียงอย่างเดียว โดยไม่จัดกลุ่มตาม resource scope ด้วย จะรวมฟอนต์สองตัวที่ต่างกันทางสายตาที่บังเอิญมีชื่อเดียวกันเข้าด้วยกัน และความผิดพลาดจะไม่ปรากฏขึ้นจนกว่าจะมีใครสังเกตเห็นตัวเลขจาก typeface ที่ผิดนั่งอยู่ในสิ่งที่ควรจะอ่านเป็นฟอนต์เดียวที่สอดคล้องกัน

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

การอ่าน FormTraversalStatus ใน Pipeline ของคุณเอง

FormTraversalStatus เปลี่ยนทุก snapshot Do ให้เป็นรายงานวินิจฉัยเล็กๆ ในตัวเอง และ pipeline ที่เพิกเฉยต่อมันกำลังทิ้งข้อมูลที่จะอธิบายการดึงข้อมูลที่ไม่สมบูรณ์ไปพอดี ftsNotApplicable หมายถึงคำสั่งนั้นไม่เคยเป็นการเรียก Form ที่ resolve แล้วตั้งแต่แรก ftsNotRequested หมายถึงการเรียกซ้ำถูกปิดสำหรับการเรียกนี้ ftsEnumerated หมายถึง Form ถูก parse และเดินผ่านสำเร็จ ftsDepthLimit และ ftsCycle ทำเครื่องหมายสองวิธีที่การลงไปถูกตัดสั้นลงโดยตั้งใจ และ ftsMalformed ครอบคลุมทุกอย่างอื่นที่หยุดการเดินผ่าน คือการอ้างอิงสตรีมที่ resolve ไม่ได้, /Matrix หรือ /BBox ที่ parse ไม่ได้ หรือ exception ที่ยกขึ้นระหว่างการรันเนื้อหาของ Form เอง กรณีสุดท้ายนั้นสำคัญในทางปฏิบัติ เพราะการเดินผ่านที่ซ้อนกันที่ล้มเหลวจะย้อนกลับเอาต์พุตบางส่วนใดก็ตามที่มันสร้างไว้แล้วสำหรับ branch นั้น ดังนั้นผู้เรียกจึงไม่ต้องเดาเลยว่า Form นั้นว่างเปล่าจริงๆ หรือแค่ระเบิดสองคำสั่งเข้าไปใน content stream ของมัน

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

ขอบเขต ต้นทุน และสิ่งนี้เข้ากับที่ไหน

content stream ของ Form ถูกถอดรหัสและ parse แค่ครั้งเดียวพอดีต่อการเรียก enumeration ไม่ว่า Form นั้นจะถูกเรียกกี่ครั้งก็ตาม เพราะ PDFlibPas cache รายการคำสั่งที่ parse แล้วไว้เทียบกับ stream object ข้างใต้ แทนที่จะ parse มันใหม่ในทุกการเรียกพี่น้อง ตราประทับสามมุมจากตัวอย่างเปิดเรื่องถูกถอดรหัสครั้งเดียวและเดินผ่านสามครั้ง ไม่ใช่ถอดรหัสสามครั้ง สิ่งที่ถูกสร้างใหม่ในทุกการเรียกจริงๆ คือทุกอย่างที่ต่างกันอย่างถูกต้องระหว่างจุดเรียกหนึ่งกับถัดไป คือ tracker ลูก, CTM ที่ concatenate แล้ว, clip ที่ตัดกันแล้ว และ resource scope การทำบัญชี CTM และ clip ต่อการเรียกนั้นเป็นกลไกเดียวกับเบื้องหลังตัวติดตาม CTM และ clipping state ของ content-stream ของ PDFlibPas ควรอ่านควบคู่กับบทความนี้สำหรับการเดินผ่าน content-stream ใดก็ตามที่ไปไกลกว่าการเรียกซ้ำของ Form เอง

มีขีดจำกัดสองอย่างที่ควรตั้งความคาดหวังไว้ก่อนที่ API นี้จะเข้าไปใน pipeline ที่ใหญ่กว่า เพดานความลึก 64 ระดับไม่ใช่ปุ่มปรับสำหรับเอกสารที่ซ้อนลึกอย่างถูกต้อง เพราะใบแจ้งหนี้, ใบแจ้งยอด และเทมเพลตรายงานจริงแทบไม่เคยซ้อน Form เกินสามหรือสี่ระดับเลย เอกสารที่ชน ftsDepthLimit จริงๆ มีแนวโน้มที่จะผิดรูปแบบหรือประสงค์ร้ายมากกว่าจะซับซ้อนผิดปกติ และควรค่าแก่การ log เป็นสัญญาณคุณภาพข้อมูล แทนที่จะ retry เงียบๆ ด้วยตัวเลขที่ใหญ่กว่า EnumPageContentStatesEx ยังเป็น API วิเคราะห์ฝั่งอ่านด้วย มันรายงานว่า content stream ทำอะไร ไม่ใช่ว่า Form ควรมองเห็นได้หรือไม่เลย ซึ่งเป็นคำถามแยกต่างหากที่ตอบโดยสถานะการมองเห็นของ Optional Content Group เมื่อตราประทับหรือลายน้ำ Form นั่งอยู่หลังชั้นที่ viewer อาจปิดไว้ การตรวจจับวงจรตาม call-chain, การแยกต่อการเรียก และการจำกัดขอบเขต resource รวมกันเป็นมุมหนึ่งของพื้นผิวการตรวจสอบ content-stream ในคอมโพเนนต์PDFlibPasสำหรับ Delphi และ C++Builder