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

HotXLS: การป้องกันแผ่นงาน การตั้งค่าหน้ากระดาษ และการพิมพ์ใน Delphi

การตั้งค่าของ worksheet สามกลุ่มไม่เกี่ยวอะไรกับค่าในเซลล์เลย แต่เกี่ยวข้องเต็ม ๆ กับพฤติกรรมของไฟล์เมื่อมันหลุดออกจากโค้ดของคุณไปแล้ว sheet protection ตัดสินว่าเซลล์ไหนที่ผู้ใช้แก้ไขได้หลังจากคุณส่งมอบ workbook page setup กำหนดทิศทางกระดาษ ขนาดกระดาษ และระยะขอบ ส่วนการตั้งค่าการพิมพ์ (แถวหัวข้อที่พิมพ์ซ้ำ การปรับสเกล และการขึ้นหน้าใหม่ด้วยมือ) ควบคุมว่าตารางที่ยาวเท่าไรก็ได้จะลงกระดาษอย่างไร ไม่มีสักอย่างในสามอย่างนี้ที่จะโผล่ให้เห็นตอนคุณกวาดตาดูข้อมูลใน viewer และทั้งสามอย่างก็พังแบบเงียบ ๆ ในสนามจริงเมื่อตั้งค่าผิด HotXLS ไลบรารีสเปรดชีตแบบ native สำหรับ Delphi และ C++Builder เปิดให้ใช้ surface ทั้งหมดสำหรับ .xls และ .xlsx ซึ่งหมายความว่ามันก็จำลองกฎสวนสามัญสำนึกของ Excel ทุกข้อที่ฝังอยู่ใน surface นั้นด้วยเช่นกัน

กฎข้อแรกในนั้นทำให้เกือบทุกคนสะดุดตั้งแต่ครั้งแรกที่ป้องกัน sheet ที่สร้างขึ้นมา แค่เรียก Protect แล้วจู่ ๆ ก็ไม่มีใครพิมพ์ลงเซลล์ไหนได้เลย รวมถึงคอลัมน์รับข้อมูลที่คุณสร้าง workbook ขึ้นมารอบมันด้วย ทั้งที่โค้ดของคุณไม่ได้แตะคอลัมน์เหล่านั้นเลยสักนิด และนั่นแหละคือเหตุผลที่มันเกิดขึ้น

ทุกเซลล์เกิดมาพร้อม lock อยู่แล้ว

ECMA-376 นิยาม locked ให้เป็นส่วนหนึ่งของ formatting record ของเซลล์ ไม่ใช่ property ของ protection เอง และค่าเริ่มต้นคือ true sheet protection เป็นเพียงสวิตช์ที่ทำให้ flag นี้มีผลบังคับใช้ ดังนั้นทั้งตารางจะพก lock flag ติดตัวมาตั้งแต่มันถูกสร้างขึ้น เพียงแต่ยังไม่ทำงาน และการเรียก Protect จะเปิดใช้งานทั้งหมดพร้อมกันในทีเดียว ทางแก้คือกำหนดลำดับให้ชัดเจน สร้าง layout ก่อน ปลด lock ของช่วงที่ผู้ใช้ต้องแก้ไขได้อย่างชัดแจ้ง แล้วค่อย protect เป็นลำดับสุดท้าย

แผนภาพลำดับการป้องกันของ HotXLS ใน Delphi ที่ทุกเซลล์เกิดมาพร้อม locked เป็น true, ช่วงอินพุตถูกปลดล็อกด้วย SetLocked ก่อน และ Sheet.Protect ที่เรียกเป็นลำดับสุดท้ายทำให้เซลล์ที่ปลดล็อกแล้วยังแก้ไขได้
เซลล์มาพร้อมสถานะล็อกตามค่าเริ่มต้น จึงควรปลดล็อกช่วงอินพุตก่อน แล้วเรียก Protect เป็นลำดับสุดท้ายเพื่อให้ยังแก้ไขได้
Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Timesheet');
  // ... เขียนแถวหัวข้อ คอลัมน์ชื่อ และสูตรคำนวณอัตราไว้ตรงนี้ ...
  Sheet.Range['B2:B50'].SetLocked(False);         // พนักงานพิมพ์ชั่วโมงทำงานตรงนี้
  Sheet.Range['F2:F50'].SetFormulaHidden(True);   // เก็บสูตรคำนวณอัตราไว้เป็นความลับ
  Sheet.Protect('review-2026');                   // ตอนนี้ lock flag เริ่มมีผลจริง
  Book.SaveAs('timesheet.xlsx');
finally
  Book.Free;
end;

SetFormulaHidden ทำสิ่งที่แยกออกไปต่างหากและมักถูกมองข้าม นั่นคือขณะที่ protection เปิดใช้งานอยู่ เซลล์ยังคงแสดงค่าที่คำนวณได้ตามปกติ แต่ formula bar จะไม่แสดงอะไรเลย เรื่องนี้สำคัญเมื่อสูตรฝังอัตราค่าบริการ มาร์จิ้น หรือน้ำหนักคะแนนที่คุณไม่อยากให้ทุกคนที่คลิกดูยอดรวมเห็น ในฝั่ง facade ของ XLS เจตนาเดียวกันนี้แสดงออกเป็นรายช่วงผ่าน IXLSRange.Locked และ FormulaHidden worksheet ฝั่งนั้นยังมี flag Allow* อีกสิบห้าตัว (AllowSort, AllowAutoFilter, AllowFormatCells และที่เหลือ) ดังนั้น sheet ที่ถูก protect ไว้ก็ยังสามารถเรียงลำดับและกรองข้อมูลได้ แทนที่จะถูกแช่แข็งเป็นเอกสารปิดผนึก

รหัสผ่านของ protection ปกป้องอะไรกันแน่

ทั้งสองฟอร์แมตเก็บรหัสผ่านของ sheet และ workbook protection ในรูปแบบ legacy hash ยาว 4 หลัก hex สิบหกบิตหมายความว่ามีสตริงนับไม่ถ้วนที่ชนกับรหัสผ่านใด ๆ ก็ตาม และเครื่องมือถอดรหัสก็หาได้แค่ค้นหาครั้งเดียว ให้มองว่า protection เป็นแค่เข็มขัดนิรภัยกันการแก้ไขโดยไม่ตั้งใจ ไม่ใช่การควบคุมสิทธิ์การเข้าถึง มันเป็นเครื่องมือที่ถูกต้องสำหรับหยุดผู้ตรวจทานไม่ให้พิมพ์ทับคอลัมน์สูตร แต่เป็นเครื่องมือที่ผิดสำหรับอะไรก็ตามที่เกี่ยวข้องกับคำว่าความลับ

ขยับขึ้นไปอีกระดับ ProtectWorkbook บน facade ของ XLSX จะล็อกโครงสร้างของ workbook ซึ่งป้องกันการเพิ่ม เปลี่ยนชื่อ ลบ หรือจัดเรียง sheet ใหม่ ให้ตั้งค่านี้เมื่อใดก็ตามที่รายการ sheet เองเป็น contract กับ parser ปลายทางที่อ้างอิง sheet ด้วยชื่อหรือตำแหน่ง sheet ที่ถูกเปลี่ยนชื่อจะทำให้การ import ฝั่งโน้นพังพอ ๆ กับการลบคอลัมน์ทิ้งไป facade ของ XLS สะท้อนโครงสร้างชั้นเดียวกันนี้ด้วย TXLSWorkbook.Protect ในระดับ workbook และการเรียก Protect รายชีท บวกกับ property isProtected สำหรับโค้ดที่ต้องตรวจสอบไฟล์ที่รับช่วงมาก่อนจะแก้ไขอะไรเข้าไป

เมื่อความต้องการคือความลับที่แท้จริง กลไกทั้งหมดจะเปลี่ยนไปคนละเรื่อง SaveAsEncrypted สร้างแพ็กเกจที่เข้ารหัสด้วย AES ภายใต้ ECMA-376 Standard Encryption scheme ซึ่งอธิบายไว้ละเอียดใน คู่มือ AES-protected XLSX output ส่วน facade ของ XLS แบบดั้งเดิมเขียนและอ่านไฟล์ .xls ที่เข้ารหัสด้วย RC4 ผ่าน EncryptionPassword และ overload แบบใส่รหัสผ่านของ Open ความต่างนี้ไม่ใช่เรื่องวิชาการเฉย ๆ sheet ที่ถูก protect เดินทางเป็น cleartext ดังนั้นเครื่องมือ zip ตัวไหนก็อ่านค่าเซลล์ได้ ในขณะที่แพ็กเกจที่เข้ารหัสจะอ่านไม่ได้เลยถ้าไม่มีรหัสผ่าน บรรทัด audit ที่บอกว่า "ไฟล์เงินเดือนต้องถูก protect" แทบทุกครั้งหมายถึงการเข้ารหัส ไม่ว่าจะใช้คำศัพท์แบบไหนก็ตาม

แผนภาพเปรียบเทียบการป้องกันชีตของ HotXLS ที่เก็บแฮช legacy 16 บิตและทิ้งค่าเซลล์เป็น cleartext ที่เครื่องมือ zip ตัวใดก็อ่านได้ กับผลลัพธ์ AES ของ SaveAsEncrypted ที่ยังอ่านไม่ได้หากไม่มีรหัสผ่าน
การป้องกันชีตเป็นเข็มขัดนิรภัยต้านการแก้ไขโดยบังเอิญ ขณะที่ค่า cleartext ยังอ่านได้ และมีเพียงการเข้ารหัส AES เท่านั้นที่ซ่อนเนื้อหา

page setup เป็นส่วนหนึ่งของ contract ของเอกสาร

พฤติกรรมการพิมพ์มองไม่เห็นบนหน้าจอ นี่คือเหตุผลที่มันมักหลุดออกไปพร้อมข้อบกพร่องบ่อยครั้ง ในวินาทีที่ลูกค้าสั่งพิมพ์ workbook หรือ export เป็น PDF ให้ผู้ตรวจสอบบัญชี ระยะขอบ การปรับสเกล และหัวข้อที่พิมพ์ซ้ำจะกลายเป็น functional requirement ที่ไม่มีใครเคยทดสอบมาก่อน บน facade ของ XLSX การตั้งค่าเหล่านี้แขวนอยู่กับ worksheet โดยตรง:

Sheet.PageLandscape := True;
Sheet.PaperSize := xlsxPaperA4;
Sheet.SetPageMargins(0.5, 0.5, 0.75, 0.75, 0.3, 0.3);
Sheet.CenterHeader := 'Monthly Timesheet';
Sheet.RightFooter := 'Page &P of &N';
Sheet.PrintArea := '$A$1:$F$60';     // bare reference: ไม่มีชื่อ sheet ตรงนี้
Sheet.PrintTitleRows := '$1:$1';     // แถวหัวข้อพิมพ์ซ้ำทุกหน้า
Sheet.FitToWidth := 1;
Sheet.FitToHeight := 0;              // ขยายลงด้านล่างตามข้อมูลที่เพิ่มขึ้น
Sheet.PrintGridlines := False;

สองบรรทัดในนั้นซ่อนกับดักไว้ สตริงของ header และ footer ใช้รหัสฟอร์แมตของ Excel: &P สำหรับเลขหน้าปัจจุบัน &N สำหรับจำนวนหน้าทั้งหมด พร้อมด้วย &L, &C และ &R สำหรับระบุสามส่วนอย่างชัดเจน กับดักอีกจุดคือ PrintArea ซึ่งตั้งใจให้รับแค่การอ้างอิงเซลล์เปล่า ๆ HotXLS เก็บมันไว้แบบไม่ระบุ qualifier แล้วเติมชื่อ sheet ให้เองตอนเขียนไฟล์ ดังนั้นถ้าคุณส่ง 'Timesheet!$A$1:$F$60' เข้าไปเองก็จะได้การอ้างอิงที่ qualify ซ้ำสองชั้นจนผิดรูปแบบ ข้อควรระวังแบบเดียวกันนี้ใช้ได้อีกชั้นหนึ่งด้วย พื้นที่พิมพ์และหัวข้อพิมพ์ถูกเก็บไว้เป็น defined name ในตัวที่ชื่อ _xlnm.Print_Area และ _xlnm.Print_Titles ดังนั้นห้ามเพิ่มรายการ _xlnm.* ผ่าน DefinedNames ด้วยมือเด็ดขาด ไม่งั้นกลไกทั้งสองจะแย่ง slot เดียวกัน

การปรับสเกลที่รอดจากปริมาณข้อมูลจริงในการใช้งาน

การผสมกันระหว่าง FitToWidth := 1 กับ FitToHeight := 0 อ่านได้ว่า "บีบคอลัมน์ให้พอดีหนึ่งหน้าเสมอ แล้วปล่อยให้ใช้กี่หน้าลงมาก็ได้ตามที่ข้อมูลต้องการ" และนี่คือค่าเริ่มต้นที่ถูกต้องสำหรับรายงานใด ๆ ที่จำนวนแถวไม่แน่นอน กับดักคือการปรับเปอร์เซ็นต์ตายตัวหรือคู่ fit-to-page เทียบกับไฟล์ทดสอบสามสิบแถว: พอป้อนการตั้งค่าเดิมเข้ากับข้อมูลจริงหกร้อยแถว ผลลัพธ์ก็จะระเบิดเป็นหลายสิบหน้าที่ถูกตัดขาด หรือไม่ก็หดเล็กจนอ่านไม่ออก ให้ปรับสเกลความกว้าง ปล่อยให้ความยาวขยายได้เอง และพิมพ์ซ้ำแถวหัวข้อผ่าน PrintTitleRows เพื่อให้หน้าที่สิบเจ็ดยังอ่านรู้เรื่องได้ด้วยตัวเอง

แผนภาพการปรับสเกลการพิมพ์ของ HotXLS ใน Delphi ที่ FitToWidth ตั้งเป็น 1 ทุกหน้าจึงกว้างเท่าแผ่นเดียว, FitToHeight ตั้งเป็น 0 หน้าจึงงอกลงล่าง, PrintTitleRows ทำซ้ำแถบหัวกระดาษ และ page break ถูกสร้างใหม่หลัง ClearAllPageBreaks
FitToWidth 1 ร่วมกับ FitToHeight 0 ทำให้ทุกหน้ากว้างหนึ่งชีต ขณะที่แถวหัวเรื่องที่ทำซ้ำและจุดตัดที่สร้างใหม่ช่วยรักษาความอ่านง่าย

การขึ้นหน้าใหม่ด้วยมือก็ต้องยึดวินัยการสร้างใหม่แบบเดียวกับทุกอย่างอื่นใน workbook ที่ถูกสร้างขึ้นมา AddRowBreak(BeforeRow) เริ่มหน้าใหม่ก่อนถึงขอบเขตของ section แต่เมื่อตัวสร้างรันซ้ำแล้วแถวขยับตำแหน่ง break เก่าที่ค้างอยู่ก็จะไปตกกลางตาราง ให้เรียก ClearAllPageBreaks ก่อนเสมอ แล้วค่อยเพิ่ม break ใหม่โดยคำนวณจากตัวนับแถวของตัวสร้างเอง แทนที่จะไปแก้ตำแหน่งเดิม บน facade ของ XLS ตัวควบคุมที่เทียบเท่ากันอยู่ที่ Sheet.PageSetup (ทิศทาง ขนาดกระดาษ ระยะขอบ สตริง header และ footer, fit-to-pages) โดยมี RepeatRows และ RepeatColumns ครอบคลุมหัวข้อพิมพ์

ตรวจสอบผลลัพธ์ก่อนที่ลูกค้าจะเป็นคนตรวจแทน

บั๊กของ protection และของการพิมพ์มีคุณสมบัติร่วมกันอย่างหนึ่ง คือตรวจสอบด้วยมือได้ง่ายมาก แต่แทบไม่เคยมีใครตรวจสอบเลย เปิดไฟล์ที่สร้างขึ้นใน Excel แล้วใช้เวลาเก้าสิบวินาทีกับมัน พิมพ์ลงในเซลล์รับข้อมูลแล้วยืนยันว่ามันรับการกดคีย์ พิมพ์ลงในเซลล์ที่ล็อกไว้แล้วยืนยันว่า prompt ของ protection ปรากฏขึ้น ตรวจสอบว่าสูตรที่ซ่อนไว้ทำให้ formula bar ว่างเปล่าจริง ๆ จากนั้นรัน Print Preview กับชุดข้อมูลขนาดจริงในการใช้งาน ไม่ใช่ตัวอย่างสามสิบแถว แล้วอ่านจำนวนหน้า แถวหัวข้อที่พิมพ์ซ้ำ และเลขหน้าใน footer พรีวิวคือขั้นตอนที่คุ้มค่ากับตัวมันเองเสมอ เพราะเรขาคณิตของการพิมพ์ขึ้นอยู่กับการตั้งค่าที่ไม่มีการ render ให้เห็นบนหน้าจอ และถ้าไม่มีเครื่องพิมพ์จริง มันก็เป็นจุดเดียวที่ความผิดพลาดของการปรับสเกลจะปรากฏให้เห็นได้เลย

มีการตั้งค่าสุดท้ายอีกอย่างที่ทำให้การตรวจสอบครบถ้วน FreezePane(ACol, ARow) ทำให้บล็อกหัวข้อยังอยู่ในสายตาขณะที่ผู้ตรวจทานเลื่อนหน้าจอ นั่นคือพฤติกรรมบนหน้าจอ ไม่ใช่พฤติกรรมการพิมพ์ แต่ผู้ตรวจทานก็ตัดสินงานส่งมอบทั้งชิ้นไปพร้อมกันอยู่ดี และ workbook ที่เริ่มต้นชีวิตมาจาก layout ที่ดูแลผ่าน designer จะได้สิ่งเหล่านี้มาเกือบทั้งหมดโดยไม่ต้องทำอะไรเพิ่ม: workflow การสร้างรายงานด้วย template เก็บ page setup ไว้ใน template ที่มนุษย์ปรับแต่งไว้แล้วกับเครื่องพิมพ์จริง และปล่อยให้โค้ดทำหน้าที่แค่เติมข้อมูลแล้ว re-apply protection อีกครั้งเมื่อ layout ลงตัวแล้ว

HotXLS คือไลบรารีสเปรดชีตแบบ native ที่เขียนด้วย Object Pascal สำหรับ Delphi และ C++Builder ส่วน API reference ฉบับเต็มของ protection และ page-setup อยู่ที่ หน้าผลิตภัณฑ์ HotXLS Delphi Component