เพื่อค้นหาว่าขนาดไฟล์ PDF จริง ๆ ไปอยู่ที่ไหน losLab PDF Library เปิดให้ใช้ AuditDocumentSpace ซึ่งจัดหมวดหมู่ indirect object ทุกตัวออกเป็นสิบสองหมวดหมู่ ได้แก่ รูปภาพ font program font dictionary content stream form XObject object stream ไฟล์แนบ metadata structure tree annotation page tree อื่น ๆ และรายงานจำนวน object ขนาดไบต์ที่จัดเก็บ และสัดส่วนเปอร์เซ็นต์ของแต่ละหมวด
สถานการณ์ที่ทำให้เครื่องมือนี้เกิดขึ้นมาเป็นเรื่องคุ้นเคย รายงาน 40 หน้าที่สร้างจากระบบของคุณออกมาเป็น 80 MB ลูกค้าถามว่าทำไม และสิ่งเดียวที่ตอบได้คือการเดา อาจเป็นรูปภาพ อาจเป็นฟอนต์ คุณจึงเปิด downsampling ส่งไฟล์ออกไป แล้วไฟล์เหลือ 74 MB เพราะน้ำหนักจริงอยู่ที่อื่นไปเลย บทความคู่หูของเรื่องนี้เรื่อง การ subset ฟอนต์และการ downsample รูปภาพ พูดถึงวิธีลดขนาด PDF ส่วนบทความนี้พูดถึงขั้นตอนที่ควรมาก่อน คือการวัดสิ่งที่คุณกำลังจะลดขนาด
ทำไมต้องวัดก่อนบีบอัด
เพราะสามวิธีการปรับแต่งมาตรฐานให้ผลตอบแทนต่างกันมากในแต่ละไฟล์ และไม่มีอะไรในไฟล์บอกคุณเลยว่าวิธีไหนใช้ได้จนกว่าจะนับจริง การ subset ฟอนต์ในเอกสารที่ฟอนต์คิดเป็นแค่ 2% ของขนาดไฟล์อยู่แล้ว คือการเสียเวลาช่วงบ่ายไปกับการขยับความคลาดเคลื่อนแบบปัดเศษ การ downsample รูปในไฟล์ที่ส่วนใหญ่เป็น content stream ที่ไม่บีบอัดก็ให้ผลลัพธ์น่าผิดหวังแบบเดียวกัน ตัวปรับแต่งไม่ใช่ส่วนที่ยาก ทุกไลบรารีมีให้อยู่แล้ว การรู้ว่าจะชี้ตัวปรับแต่งไหนไปที่ไฟล์นี้ต่างหากที่ยาก และนั่นเป็นคำถามเชิงบัญชี ไม่ใช่คำถามเชิงการบีบอัด การตรวจสอบยังจับกรณีที่ไม่มีตัวปรับแต่งใดเป็นคำตอบด้วย ไฟล์ที่พบว่า 60% เป็นไฟล์แนบไม่ต้องการการบีบอัดที่ดีขึ้น มันต้องการการพูดคุยว่าไฟล์แนบเหล่านั้นควรอยู่ในเอกสารหรือไม่ และไฟล์ที่ 30% เป็น structure tree กำลังจ่ายต้นทุนของ accessibility tagging ซึ่งมักเป็นต้นทุนที่ตั้งใจและไม่ควรถูกตัดทิ้งไปเงียบ ๆ เมื่อไบต์ถูกจัดสรรแล้ว คุณกำลังตัดสินใจเชิงผลิตภัณฑ์โดยมีตัวเลขรองรับ แทนที่จะเอื้อมมือไปกดสวิตช์ที่อยู่ใกล้ที่สุด
รายงานสิบสองหมวดหมู่มีอะไรบ้าง
AuditDocumentSpace คืนค่า string list handle แทนที่จะเป็น record ดังนั้นรายงานจึงอยู่รอดผ่าน flat DLL และ COM facade โดยไม่เปลี่ยนแปลง รายการนี้มีบรรทัดสรุป Total,Objects,Bytes,100.0 ตามด้วยบรรทัด Category,Objects,Bytes,Percent จำนวนสิบสองบรรทัดพอดี ในลำดับคงที่ที่เป็นส่วนหนึ่งของสัญญา ได้แก่ Images, Font programs, Font dictionaries, Content streams, Form XObjects, Object streams, Embedded files, Metadata, Structure tree, Annotations, Page tree, Other รวมสิบสามบรรทัดเสมอ แม้หมวดหมู่จะว่างเปล่าก็ตาม
var
Lib: TPDFlib;
ListID, I: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('report.pdf', '') <> 1 then
Exit;
ListID := Lib.AuditDocumentSpace; // 0 when no document is selected
if ListID = 0 then
Exit;
try
// GetStringListItem is 1-based: items run 1..GetStringListCount
for I := 1 to Lib.GetStringListCount(ListID) do
Memo1.Lines.Add(Lib.GetStringListItem(ListID, I));
finally
Lib.ReleaseStringList(ListID);
end;
finally
Lib.Free;
end;
end;
รายละเอียด Delphi หนึ่งอย่างในลูปนั้นจะกัดคุณพอดีหนึ่งครั้ง GetStringListItem ใช้ดัชนี item แบบเริ่มที่ 1 ตรงกับ GetStringListCount และดัชนีนอกช่วงจะคืนสาย string ว่างเปล่าแทนที่จะ raise เขียนลูปเป็น for I := 0 to Count - 1 ตามความเคยชิน แล้วคุณจะได้บรรทัดแรกว่างเปล่า บรรทัดสุดท้ายหายไปเงียบ ๆ และไม่มี exception ใดบอกคุณว่าดัชนีผิด ตัวรายงานเองจะดูเกือบถูกต้อง ซึ่งเป็นรูปแบบความล้มเหลวที่แย่ที่สุดที่เครื่องมือวินิจฉัยจะมีได้
ทำไมการตรวจสอบใช้ความยาวที่จัดเก็บแทนขนาดที่ถอดรหัสแล้ว
เพราะความยาวที่จัดเก็บเป็นทั้งตัวเลขที่คุณต้องการและตัวเลขที่หาได้ถูก แต่ละ indirect object พก TPDFIndObj.FLength ความยาวไบต์ดิบที่ object นั้นครองในไฟล์ตามที่ parse ได้ การใช้มันหมายความว่ารูปภาพ DCTDecode ขนาด 900 KB จะถูกรายงานเป็น 900 KB ซึ่งเป็นไบต์ที่คุณต้องเสียในดิสก์ แทนที่จะเป็น 40 MB ของ RGB sample ที่มันถอดรหัสออกมา มันยังหมายความว่าการตรวจสอบไม่จำเป็นต้องถอดรหัสอะไรเลย object ที่โหลดแบบ lazy ยังคง lazy อยู่ filter ยังไม่ถูกรัน และการตรวจสอบไฟล์ 500 MB คือการผ่าน object header เท่านั้น ไม่ใช่รอบการคลายบีบอัดแบบเต็ม
กฎที่สองคือแนวป้องกันการนับซ้ำ เมื่อ object อยู่ภายใน object stream ที่บีบอัดไว้ ระบุด้วย FObjStrNum ที่ไม่เป็นศูนย์ จำนวนไบต์ของมันจะถูกบันทึกเป็นศูนย์ พื้นที่จัดเก็บของมันถูกจ่ายไปแล้วครั้งหนึ่งโดย container stream ซึ่ง ISO 32000-1 §7.5.7 กำหนดให้เป็น stream ประเภท /Type /ObjStm ที่เก็บ object หลายตัวไว้ในหนึ่ง payload ที่บีบอัดแบบ Flate การคิดค่าให้สมาชิกแต่ละตัวตามส่วนของมันแล้วยังคิดค่า container อีกครั้งจะทำให้ยอดรวมพองเกินขนาดไฟล์จริง เรื่องนี้ส่งผลโดยตรงต่อวิธีที่คุณอ่านผลลัพธ์ ซึ่งครอบคลุมด้านล่างและในรายละเอียดเพิ่มเติมในบทความเรื่อง object stream และ cross-reference stream
ทำไม font program ถึงจัดหมวดหมู่ตัวเองไม่ได้
เพราะไฟล์ font TrueType ที่ฝังใน PDF ไม่มีเครื่องหมายบอกว่ามันคืออะไร ISO 32000-1 §9.8.1 กำหนดให้ embedded font program คือค่าของ /FontFile, /FontFile2 หรือ /FontFile3 ใน font descriptor และ stream dictionary ที่ปลายอีกด้านของ reference นั้นพก /Length1 และคีย์ filter แต่ไม่มี /Type และไม่มี /Subtype ที่ระบุว่ามันคือฟอนต์ มองแยกส่วนเดียว มันคือ binary stream นิรนาม มีแต่ descriptor ที่ชี้ไปหามันเท่านั้นที่รู้ว่ามันคืออะไร ความไม่สมมาตรแบบเดียวกันนี้ปรากฏกับ annotation ด้วย §12.5.2 ทำให้ /Type /Annot เป็นทางเลือกใน annotation dictionary ดังนั้นสัญญาณที่เชื่อถือได้คือการเป็นสมาชิกของ array /Annots ของหน้า ไม่ใช่ dictionary เอง
ดังนั้นการจัดหมวดหมู่จึงรันสองรอบ รอบแรกอ่าน /Type และ /Subtype ของแต่ละ object เองและเก็บชัยชนะง่าย ๆ คือ /ObjStm, /Subtype /Image, /Subtype /Form, /Type /Font และ /Type /FontDescriptor, /Metadata, /EmbeddedFile และ /Filespec, /StructTreeRoot และ /StructElem, /Annot, /Page และ /Pages ที่เหลือทั้งหมดจะถูกจัดไว้ในหมวด Other ชั่วคราวก่อน รอบที่สองจะเดินตามฝั่งที่อ้างอิงและปรับแก้ไข dictionary ของแต่ละหน้าจะจัดสรร /Contents ของตัวเองใหม่ไปเป็น content stream, entry ของ /Annots ไปเป็น annotation และ /Thumb ไปเป็นรูปภาพ ในขณะที่ font dictionary แต่ละตัวเดินตาม descriptor chain ของตัวเอง
// Shape of the second pass: the referrer names the object
Descriptor := DictOf(FontDict.FindValueByKeyName('FontDescriptor'));
if Assigned(Descriptor) then
begin
MarkRef(FontDict.FindValueByKeyName('FontDescriptor'), catFontDicts);
MarkRef(Descriptor.FindValueByKeyName('FontFile'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile2'), catFontPrograms);
MarkRef(Descriptor.FindValueByKeyName('FontFile3'), catFontPrograms);
end;
// Type0 fonts keep the descriptor one level down
Descendants := FontDict.FindValueByKeyName('DescendantFonts', True);
if (Descendants is TPDFArray) and (TPDFArray(Descendants).Count > 0) then
MarkFontProgramRefs(DictOf(TPDFArray(Descendants).Item[0]));
การอ่านรายงานและเลือกขั้นตอนถัดไป
อ่านสัดส่วนก่อน จำนวน object เป็นอันดับสอง และถือว่าช่องว่างขนาดใหญ่ระหว่างสองอย่างเป็นสัญญาณ PDF สมัยใหม่ใส่ dictionary เล็ก ๆ ส่วนใหญ่ไว้ใน object stream ดังนั้น Page tree และ Structure tree มักแสดง object เป็นสิบ ๆ ตัวโดยที่ไบต์เกือบเป็นศูนย์ ต้นทุนจริงของมันถูกพับเข้าไปในบรรทัด Object streams แล้ว ถ้า Object streams เองมีขนาดใหญ่ ไฟล์นั้นมี structure ลักษณะ metadata หนาแน่น มากกว่าเนื้อหา และคันโยกที่ควรใช้คือการตัด object ทิ้ง ไม่ใช่การบีบอัด appearance stream ของ annotation ก็มีพฤติกรรมคล้ายกัน มันพก /Subtype /Form ดังนั้นเอกสารที่มีตราประทับหนัก ๆ จะแสดงน้ำหนักไว้ที่ Form XObjects ในขณะที่บรรทัด Annotations ยังคงเล็ก
function CategoryShare(Lib: TPDFlib; ListID: Integer;
const Category: string): Double;
var
I: Integer;
Parts: TArray<string>;
Inv: TFormatSettings;
begin
Result := 0;
Inv := FormatSettings;
Inv.DecimalSeparator := '.'; // the report is locale-independent
for I := 2 to Lib.GetStringListCount(ListID) do // line 1 is Total
begin
Parts := string(Lib.GetStringListItem(ListID, I)).Split([',']);
if (Length(Parts) = 4) and SameText(Parts[0], Category) then
Exit(StrToFloatDef(Parts[3], 0, Inv));
end;
end;
มีข้อเท็จจริงเกี่ยวกับการจัดรูปแบบสองอย่างที่สำคัญถ้าคุณ parse เปอร์เซ็นต์แทนที่จะแสดงผลตรง ๆ ตัวคั่นทศนิยมเป็นจุด (period) เสมอ ไม่ว่าเครื่องนั้นจะตั้ง locale อะไร ดังนั้นการ parse ด้วย FormatSettings แวดล้อมบนเครื่องที่ตั้งภาษาเยอรมันหรือฝรั่งเศสจะล้มเหลว หรือแย่กว่านั้นคืออ่านค่าผิด และเลขศูนย์ท้ายจะถูกตัดออก ดังนั้นหมวดหมู่ที่มีสัดส่วน 40% พอดีจะพิมพ์เป็น 40 ไม่ใช่ 40.0 อย่าสันนิษฐานว่ามีตำแหน่งทศนิยมคงที่ เมื่อมีสัดส่วนอยู่ในมือ การนำไปใช้งานต่อก็เป็นกลไกตรงไปตรงมา สัดส่วน Images ที่มากเป็นหลักชี้ไปที่ DownsampleImages สัดส่วน Font programs ที่มากเป็นหลักชี้ไปที่ SubsetEmbeddedFonts และ Content streams ที่หนักชี้ไปที่ CompressContent
สิ่งที่การตรวจสอบตั้งใจไม่บอกคุณ
ยอดรวมคือผลรวมของ indirect object และไฟล์ PDF มีขนาดมากกว่า object ของมันเล็กน้อยเสมอ file header, trailer, ช่องว่างระหว่าง object และ cross-reference table แบบดั้งเดิมไม่ใช่ indirect object ดังนั้นไบต์เหล่านั้นจึงไม่ถูกจัดสรรให้กับอะไรเลย และยอดรวมของการตรวจสอบจึงต่ำกว่าขนาดไฟล์บนดิสก์เล็กน้อย cross-reference stream นั้นต่างออกไป มันเป็น object จริงที่มี /Type /XRef ดังนั้นในไฟล์สมัยใหม่ไบต์เหล่านั้นจะปรากฏในหมวด Other พฤติกรรมทั้งสองไม่ใช่ข้อบกพร่อง แต่ถ้าคุณกำลังกระทบยอดผลการตรวจสอบกับจำนวนไบต์จากระบบไฟล์ นี่คือจุดที่ความต่างมาจาก
มีขอบเขตอีกสองข้อที่ควรพูดให้ชัด ข้อแรก ตัวเลขเหล่านี้อธิบายไฟล์ที่ถูกโหลดแล้ว ไม่ใช่ไฟล์ที่กำลังถูกเขียนขึ้น สำหรับ object ที่สร้างในหน่วยความจำที่ยังไม่มีความยาวจัดเก็บ ขนาดจะย้อนกลับไปใช้ผลลัพธ์ serialized พร้อมค่าประมาณสำหรับ stream dictionary ซึ่งเป็นการประมาณของการเขียนในอนาคต ไม่ใช่การวัดจริง ตรวจสอบหลังจากบันทึกและโหลดใหม่ถ้าต้องการตัวเลขที่แม่นยำ ข้อสอง บรรทัด Other ที่หนามากคือข้อค้นพบ ไม่ใช่รายงานข้อบกพร่อง มันมักหมายถึง object กำพร้าที่ไม่มีอะไรอ้างอิงถึงอีกแล้ว ซึ่งเป็นงานของ mark-and-sweep garbage collection มากกว่าเป็นงานของขั้นตอนการบีบอัดใด ๆ
เมื่อใช้แบบนี้ การตรวจสอบเปลี่ยนรูปแบบของบทสนทนา แทนที่จะเดารายงาน 80 MB คุณเปิดมัน เรียกฟังก์ชันครั้งเดียว แล้วอ่านว่ารูปภาพคือ 8% font program คือ 61% และเอกสารฝัง font program เต็มรูปแบบเก้าตัวเพื่อรองรับ house style ที่ใช้แค่สามแบบตัวอักษร นั่นคือคำตอบที่แก้ไขได้พร้อมตัวเลขกำกับ AuditDocumentSpace พร้อมกับขั้นตอนการปรับแต่งที่มันชี้ทางให้ ถูกจัดส่งอยู่ใน losLab PDF Library สำหรับ Delphi และ C++Builder ที่ซึ่งหน้าเอกสารอ้างอิงบันทึกรายการหมวดหมู่ทั้งหมดและ string-list API รอบ ๆ มันไว้