preflight ของคุณรายงานว่าไฟล์นี้ผ่าน PDF/UA แล้ว veraPDF เปิดไฟล์เดียวกันและระบุ Figure ที่ไม่มี alternate text ตามข้อ 7.3 ทั้งสองเครื่องมือไม่ได้ผิด และช่องว่างระหว่างมันคือปัญหาทั้งหมดของการตรวจ accessibility ด้วยการสแกนไบต์ การตรวจระดับไบต์ยืนยันได้แค่ว่าไฟล์ บอกว่า มันถูกแท็กไว้ มันพบ /StructTreeRoot, /MarkInfo /Marked true, pdfuaid:part ในแพ็กเกจ XMP, ชื่อเอกสาร, ภาษา สิ่งเหล่านี้คือ markers ของรูปแบบ และจำเป็นต้องมี แต่บอกไม่ได้เลยว่า figure จริงบนหน้า 4 มีคำอธิบายที่ screen reader อ่านออกเสียงได้หรือไม่ คำตอบนั้นอยู่ใน tag tree และถ้าจะรู้จริงก็ต้องไล่เดิน tree นั้น
PDFium Component เป็นไลบรารี PDF แบบ native VCL สำหรับ Delphi และ C++Builder และ ValidatePdfUa ทำทั้งสองรอบการตรวจ รอบระดับไบต์จัดการ markers ของรูปแบบ ส่วนชั้นบนเป็นการตรวจ structure tree ที่โหลด tagged tree ที่กำลังใช้งาน เดินครบทุก element และตรวจชุดกฎเนื้อหาที่มั่นใจได้สูงซึ่งถ้าขาด attribute ไปคือ accessibility defect จริง ไม่ใช่เรื่องสไตล์ บทความนี้พูดถึงรอบที่สองนี้ ว่าตรวจอะไร ทำไม logic ของกฎเป็น pure function ที่ไม่มี DLL อยู่ข้างใต้ และจุดที่มันตั้งใจหยุดไว้ตรงไหน
ทำไมการสแกนไบต์จึงมองไม่เห็น Alt ที่หายไป
ISO 14289-1 (PDF/UA-1) เป็นชั้นของข้อกำหนดที่วางทับบน ISO 32000 ข้อกำหนดบางข้อเป็นโครงสร้างและมองเห็นได้จากไฟล์ดิบ เช่น catalog ต้องประกาศ structure tree, viewer preferences ต้องตั้ง DisplayDocTitle, fonts ต้องฝังไว้ token scanner ที่ตัด stream body ออกแล้วจับ name tokens ตามขอบ delimiter สามารถตรวจทั้งหมดนี้ได้ และ ValidatePdfUaCompliance ของ PDFium ก็ทำแบบนั้นกับข้ออย่าง 7.1, 7.18 และ 7.21
แต่ "ทุก Figure ต้องมี alternate text" ไม่ใช่คุณสมบัติของไวยากรณ์ไฟล์ มันเป็นคุณสมบัติของ logical structure - tree ของ tagged elements ที่แมป content ไปยังความหมาย Alt ของ Figure อาจอยู่ใน dictionary ของ structure element, อาจมาจาก span /ActualText, หรือมาจาก custom type ที่ถูก role-map คุณหาอย่างเชื่อถือได้ด้วยการ grep /Alt ใน byte stream ไม่ได้ เพราะสตริงนั้นโผล่ในบริบทอื่นได้ อาจถูกบีบอัดอยู่ใน object stream และไม่บอกเลยว่ามันเป็นของ structure element ไหน วิธีตอบคำถามอย่างซื่อตรงคือถาม structure tree ของเอกสารเอง element ต่อ element เหมือนที่ veraPDF และ PAC ตรวจ นั่นคือเส้นแบ่งที่ Tier-1 checks ของ PDFium ถูกออกแบบมาให้ยืนอยู่บนมัน: byte scan สำหรับรูปแบบ tree walk สำหรับเนื้อหา
การอ่าน tag tree ที่มีชีวิต
วัตถุดิบคือ TPdf.GetStructureElements (ซึ่งเปิดให้เรียกผ่าน property StructureElements ด้วย) โดยมันคืนค่า TPdfStructureElements ซึ่งเป็น flat array ของ TPdfStructureElement records ตามลำดับของเอกสาร แต่ละ record คือ projection ของ structure element หนึ่งตัวผ่าน accessor ของ PDFium พร้อม field ที่กฎ accessibility ต้องใช้จริง ๆ:
type
TPdfStructureElement = record
Level: Integer; // depth in the tag tree
ParentIndex: Integer; // index of parent element, or -1
TypeName: WString; // standard /S name: Figure, Formula, Note...
Title: WString; // /T
AlternateText: WString; // /Alt (FPDF_StructElement_GetAltText)
ActualText: WString; // /ActualText
Expansion: WString; // /E
ID: WString; // /ID (FPDF_StructElement_GetID)
Language: WString; // /Lang
MarkedContentIDs: TPdfIntegerArray;
// ... child bookkeeping fields
end;
ฟิลด์ TypeName คือจุดที่ validator ใช้เป็นแกน มันมาจาก FPDF_StructElement_GetType ซึ่งคืน structure type มาตรฐานของ element หรือชื่อ /S หลังจาก PDFium resolve role map แล้ว AlternateText มาจาก FPDF_StructElement_GetAltText, ActualText มาจาก FPDF_StructElement_GetActualText, และ ID มาจาก FPDF_StructElement_GetID เพราะ array นี้เป็นแบบ flat และเรียงลำดับแล้ว validator จึงมองเอกสารทั้งฉบับได้ในครั้งเดียวแทนที่จะต้อง recurse ซึ่งสำคัญกับกฎเดียวที่เป็นระดับ global ไม่ใช่ระดับ element
ตัวตรวจเป็น pure function และตั้งใจให้เป็นแบบนั้น
logic ของกฎไม่ได้อยู่ใน method ที่คุยกับ DLL มันเป็น pure function แบบ standalone และ public:
function ValidatePdfUaStructureElements(
const Elements: TPdfStructureElements): TPdfUaValidationIssues;
มันรับ flat array ของ element แล้วคืนชุด issue มันไม่เรียก PDFium, ไม่เปิดเอกสาร, ไม่แตะ state ส่วนกลาง การแยกแบบนี้ตั้งใจทำ และคุ้มสองชั้น อย่างแรกคือการทดสอบ คุณสร้าง TPdfStructureElements จำลองใน unit test ได้ เช่น Figure ที่ไม่มี Alt, Formula ที่มีข้อความเข้าถึงได้เฉพาะใน ActualText, Notes สองตัวที่ใช้ ID เดียวกัน แล้ว assert กับผลลัพธ์ได้โดยไม่ต้องมี pdfium.dll เลย logic ของกฎถูกยืนยันแบบ offline ส่วนการเดิน DLL ถูกยืนยันแยกด้วย smoke test กับเอกสารจริงที่จะข้ามไปเมื่อไม่มีไลบรารี
อย่างที่สองคือความชัดเจนของความรับผิดชอบ TPdf.ValidatePdfUa รับภาระส่วนยุ่งยากไว้ คือโหลดแต่ละหน้า ดึง element และรวมมันเข้าไว้ด้วยกัน แล้วส่ง array ที่สะอาดให้ pure checker "Get the data" (DLL, side effects, lifetime) กับ "judge the rules" (pure, deterministic) ไม่ปนกัน เมื่อกฎต้องเปลี่ยน คุณก็เปลี่ยน function ที่ไม่มี I/O
กฎทั้งสามตรวจอะไรจริง ๆ
การตรวจแบบ structure-tree จะเพิ่ม issue 3 ค่า ต่อท้าย TPdfUaValidationIssues เพื่อให้ enum คง ABI-stable สำหรับ caller เดิม: pvuaiFigureMissingAlt, pvuaiFormulaMissingAlt, และ pvuaiNoteMissingId เนื้อหาสั้นพอที่จะไล่เหตุผลได้ครบถ้วน:
for I := 0 to High(Elements) do
begin
T := string(Elements[I].TypeName);
if T = 'Figure' then
begin
// §7.3 — a Figure needs an alternate representation:
// an Alt entry OR ActualText. Flag only when BOTH are empty.
if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
Include(Result, pvuaiFigureMissingAlt);
end
else if T = 'Formula' then
begin
// §7.7 — same rule as Figure: Alt OR ActualText.
if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
Include(Result, pvuaiFormulaMissingAlt);
end
else if T = 'Note' then
begin
// §7.9 — every Note must have a unique ID.
NoteId := string(Elements[I].ID);
if NoteId = '' then
Include(Result, pvuaiNoteMissingId)
else
for J := 0 to I - 1 do
if (string(Elements[J].TypeName) = 'Note') and
(string(Elements[J].ID) = NoteId) then
begin
Include(Result, pvuaiNoteMissingId);
Break;
end;
end;
end;
ข้อ 7.3 ว่าด้วย Figure: element ประเภท Figure ต้องมี text alternative รุ่นแรกของการตรวจนี้มองแค่ Alt entry จึงเข้มกว่าตัวตรวจอ้างอิง PDF/UA ยอมรับ Figure ที่ส่ง accessible text ผ่าน ActualText แทนได้ เพราะ replacement text ก็เป็น alternative representation ที่ใช้ได้ ดังนั้นกฎนี้จะ flag Figure ก็ต่อเมื่อ ทั้ง Alt และ ActualText ว่างอยู่ ข้อ 7.7 ครอบ Formula และหลังแก้ให้ถูกทางแล้วก็ใช้การทดสอบแบบ Alt-or-ActualText เดียวกัน ตัวอย่างใน conformance corpus ที่ให้ accessible text ของ Formula ผ่าน ActualText อย่างเดียวเคยถูกปฏิเสธผิด ๆ จนกว่าจะปรับ branch ของ Formula ให้สอดคล้องกับ Figure
ข้อ 7.9 ต่างออกไปในเชิงธรรมชาติ Note ต้องมี /ID และ ID นั้นต้องไม่ซ้ำทั่วทั้งเอกสาร ID ที่หายไปคือความล้มเหลวระดับ element ส่วน ID ที่ ซ้ำ คือความสัมพันธ์ระหว่างสอง element นั่นจึงเป็นเหตุผลที่ array แบบ flat สำคัญ สำหรับ Note แต่ละตัว checker จะไล่ย้อนกลับไปดู element ที่เห็นมาก่อนหน้า และ flag การชนกับ Note ก่อนหน้าที่ถือ ID เดียวกัน ต้นทุนคือ O(n²) ตามจำนวน Note อย่างที่เห็นตรง ๆ ซึ่งไม่สำคัญกับเอกสารจริง และทำให้ฟังก์ชันยังคงเป็นลูปเดียวที่อ่านง่ายโดยไม่ต้องมีดัชนีเสริมคอย sync
สะสมข้ามหน้าเพื่อให้ความไม่ซ้ำเป็นระดับเอกสาร
PDFium เปิด structure element ต่อหน้า ไม่ได้เปิดเป็นระดับเอกสาร ดังนั้น orchestration ใน ValidatePdfUa ต้องรวบรวมมันก่อนกฎจะรัน มันเดินทีละหน้าด้วย FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage โดยไม่สนว่าขณะนั้น component เปิดหน้าไหนอยู่ แล้วต่อ element ของทุกหน้าเข้าเป็น array เดียว จากนั้นจึงเรียก pure checker:
// inside TPdf.ValidatePdfUa, after the byte-level pass
if (FDocument <> nil) and
(not (pvuaiMissingStructTreeRoot in Result.Issues)) then
begin
AllElems := nil;
PageTotal := FPDF_GetPageCount(FDocument);
for I := 0 to PageTotal - 1 do
begin
Page := FPDF_LoadPage(FDocument, I);
if Page = nil then Continue;
try
PageElems := GetStructureElementsForPage(Page);
finally
FPDF_ClosePage(Page);
end;
// append PageElems into AllElems ...
end;
Result.Issues := Result.Issues + ValidatePdfUaStructureElements(AllElems);
end;
การสะสมนี่เองที่ทำให้การตรวจความไม่ซ้ำของข้อ 7.9 ถูกต้อง Note สองตัวบนคนละหน้าสามารถใช้ ID เดียวกันได้ ถ้าคุณตรวจทีละหน้า คุณจะไม่มีวันเห็น collision เพราะชุด element ของแต่ละหน้าดูสอดคล้องในตัวเอง การสร้าง array เดียวที่ครอบทั้งเอกสารคือวิธีเดียวที่ทำให้ duplicate โผล่ขึ้นมา จุดป้องกันด้านหน้าก็ควรสังเกตด้วย tree walk จะทำงานก็ต่อเมื่อ byte-level pass ไม่ได้รายงาน pvuaiMissingStructTreeRoot เอกสารที่ไม่มี tag tree ไม่มี tree ให้เดินอยู่แล้ว และถูก flag ว่าขาด structure root ไปแล้ว ดังนั้นการโหลดแต่ละหน้าจึงถูกข้ามทั้งหมด รอบลึกนี้จึงแทบไม่เสียค่าใช้จ่ายกับเอกสารที่ไม่ได้ประโยชน์จากมัน
อนุรักษ์นิยมโดยตั้งใจ: พลาดเงียบ ๆ ไม่ร้องเกินจริง
คุณสมบัติสำคัญที่สุดของ validator ตัวนี้คือสิ่งที่มันปฏิเสธจะทำ มันจับเฉพาะชื่อ type มาตรฐาน /S ที่ FPDF_StructElement_GetType คืนตรง ๆ เท่านั้น คือ Figure, Formula, Note เอกสารที่กำหนด custom type แล้ว role-map ให้เป็น Figure จะรายงานชื่อของมันเองขึ้นมา ขึ้นอยู่กับว่า PDFium resolve type อย่างไร เมื่อเป็นแบบนั้น checker ก็จะไม่รู้จักและเงียบไว้ นั่นคือ false negative และเป็นพฤติกรรมที่ตั้งใจไว้ กฎการออกแบบคือ รายงานน้อยเกินไป ดีกว่าสร้าง false positive เพราะ preflight tool ที่ร้องผิดกับไฟล์ที่ conformant จะฝึกให้ผู้ใช้เมินมัน และ validator ที่ถูกเมินย่อมแย่กว่าไม่มีเสียอีก รูปภาพตกแต่งอยู่ใน artifact stream ไม่ได้อยู่ใน structure tree ดังนั้นตั้งแต่แรกมันจะไม่โผล่เป็น Figure คุณจึงจะไม่โดนเตือน "missing Alt" จากเส้นพื้นหลังที่ถูก mark เป็น artifact อย่างถูกต้อง
นี่คือเหตุผลด้วยว่าทำไมขอบเขตจึงคงไว้แค่สามกฎ การซ้อนระดับ heading (ข้อ 7.4), scope ของ table header (7.5), และการตรวจ role-map cycle (7.1) ล้วนเป็นข้อกำหนด PDF/UA ที่ถูกต้อง แต่ถ้าจะตรวจให้ดีต้องใช้การวิเคราะห์ graph และ attribute จริง ๆ และถ้าตรวจแบบผิวเผินก็จะสร้าง false positive ตามที่ดีไซน์ห้ามตรง ๆ PDF/UA อนุญาตรูปแบบหัวข้ออย่าง H1, H2, H3, H3 ซึ่งกฎง่าย ๆ แบบ "ต้องเพิ่มขึ้นอย่างเคร่งครัด" จะปฏิเสธผิด ๆ กฎเหล่านั้นจึงถูกปล่อยให้เครื่องมือตรวจ conformance เฉพาะทางรับไป ส่วนชุด Tier-1 คือ subset ที่ attribute ที่หายไปตีความได้ชัดเจน
ขอบเขตที่ควรรู้ให้ชัด
มีข้อจำกัดสองอย่างที่ควรรู้ก่อนเอาไปผูกกับ release gate อย่างแรก checker จะดีเท่ากับสิ่งที่ PDFium อ่านออกจาก structure element ได้เท่านั้น ไฟล์ใน conformance corpus บางชุดที่ reference validators ผ่าน ใช้กลไก alternate-text ที่ PDFium ไม่ได้แสดงออกมา ดังนั้น FPDF_StructElement_GetAltText จึงคืนค่าว่างแม้ว่าไฟล์จะ conformant จริง pure checker จึง "ตรวจถูก" ว่าขาด Alt บนข้อมูลที่ไม่ครบ ซึ่งเป็น false positive ที่มาจาก coverage ของ accessor ใน DLL ไม่ใช่ logic ของกฎ การผ่อนกฎเพื่อรองรับเคสเหล่านี้จะทำให้มันมองไม่เห็นความล้มเหลวจริงที่ตั้งใจจับ ดังนั้นจึงบันทึกไว้ว่าเป็นข้อจำกัดที่ทราบแล้วของ PDFium แทนที่จะปิดทับไว้
อย่างที่สอง นี่คือ preflight ไม่ใช่ certification Tier-1 จับข้อผิดพลาดด้านเนื้อหาที่มั่นใจได้สูงซึ่งการสแกนไบต์จับไม่ได้ในเชิงโครงสร้าง และทำได้โดยไม่ส่งสัญญาณผิด แต่ conformance แบบเต็มของ PDF/UA รวมถึง semantics ของ heading, structure ของ table, และความถูกต้องของ reading order ยังต้องพึ่ง validator แบบสมบูรณ์และท้ายที่สุดมนุษย์ผู้ตรวจ ใช้ ValidatePdfUa เพื่อทำให้ defect ที่ชัดเจนล้มเร็วและประหยัดใน pipeline ของคุณ แล้วค่อยให้ veraPDF หรือ PAC เป็นผู้ตัดสินสุดท้าย การเดิน structure tree แบบเดียวกันนี้ยังเป็นฐานของการสร้าง ตัวอ่าน PDF ที่เข้าถึงได้ใน Delphi ซึ่ง tag tree เป็นตัวกำหนด reading order และข้อความที่อ่านออกเสียง และยังเสริมงานระดับ metadata ใน การทบทวน PDF annotations จาก Delphi ด้วย
API ของ structure-tree และ validator ValidatePdfUa ที่แสดงที่นี่มาพร้อมกับ PDFium Component สำหรับ Delphi และ C++Builder (VCL) และ Lazarus/FPC (LCL) หน้า product มีลิงก์ไปยัง API reference แบบเต็ม รวมถึง layout ของ record TPdfStructureElement ทั้งหมดและ enumeration ของ issue ที่รองรับการตรวจเหล่านี้