ผู้อ่านที่มีสายตาเลือนราง (low vision) มองไม่เห็นข้อความสีดำบนหน้ากระดาษสีขาวที่ค่าคอนทราสต์เริ่มต้น จึงขอโหมดมืด (dark mode) คำตอบแบบไร้เดียงสาคือกลับสีทุกพิกเซลของหน้าที่เรนเดอร์แล้ว วิธีนี้ส่งมอบได้ภายในหนึ่งสัปดาห์แล้วก็พังในวันถัดมา: ภาพถ่ายที่สแกนมากลับกลายเป็นเหมือนฟิล์มเนกาทีฟ รอยไฮไลต์สีเหลืองของผู้อ่านกลายเป็นรอยเปื้อนสีน้ำเงินที่อ่านไม่ออก และมีคนถามว่าทำไมสิ่งพิมพ์ออกมาเป็นสีดำทึบ ฟีเจอร์นี้คุ้มค่าที่จะสร้างขึ้นจริง ๆ และก็ง่ายจริง ๆ ที่จะทำได้แค่ครึ่งเดียว ช่องว่างระหว่างสองผลลัพธ์นี้อยู่ที่แนวคิดเดียว: การตัดสินใจเรื่องสีแต่ละครั้งควรอยู่ที่จุดใดจุดหนึ่งเฉพาะเจาะจงใน render pipeline และการกลับสีคือเครื่องมือที่ผิดเมื่อนำไปใช้ในขั้นตอนที่ผิด โค้ดในบทความนี้ใช้ PDFium Component ซึ่งเป็นตัวแสดงผลบน PDFium สำหรับ Delphi, C++Builder และ Lazarus ที่ API การเรนเดอร์เปิดให้เข้าถึงแต่ละขั้นตอนเหล่านั้นแยกจากกัน
ฟิลเตอร์คือสถานะการแสดงผล ไม่ใช่สถานะของเอกสารเด็ดขาด
มีกฎข้อเดียวที่ป้องกันบั๊กหมวดที่แย่ที่สุดในเรื่องนี้ได้: โหมดการอ่านเปลี่ยนแค่วิธีที่ bitmap ถูกสร้างขึ้นหรือถูกประมวลผลภายหลัง (post-process) เท่านั้น ไม่มีอะไรอื่นอีก ไบต์ของ PDF ยังคงไม่ถูกแตะต้อง ทุกโหมดย้อนกลับได้ด้วยการเรนเดอร์ใหม่ และคำสั่ง "save" จะไม่มีวันเขียน appearance ที่ผ่านฟิลเตอร์กลับลงไปในไฟล์เด็ดขาด เรื่องนี้ฟังดูชัดเจนอยู่แล้ว จนกว่าผู้ตรวจทานด้านกฎหมายจะพิมพ์สัญญาในขณะที่ฟิลเตอร์เปิดใช้งานอยู่ แล้วยื่นเวอร์ชันที่กลับสีแล้วเข้าแฟ้ม ถึงตอนนั้นคำถามที่ว่า "การพิมพ์ใช้ appearance ของเอกสารเองหรือของหน้าจอ" ก็กลายเป็นคำถามที่สมควรมีคำตอบชัดเจนอยู่ในสเปกของคุณ ไม่ใช่เรื่องบังเอิญของเส้นทางโค้ด ให้เก็บการตั้งค่าฟิลเตอร์ไว้ในสถานะของตัวแสดงผล นำไปใช้ตอนเรนเดอร์ และทำให้ทุกเส้นทางการส่งออกประกาศให้ชัดว่าใช้ appearance แบบไหน
กฎข้อนี้คุ้มค่าถึงสองต่อ ความสามารถในการย้อนกลับได้มาฟรี ๆ เพราะการสลับโหมดเรนเดอร์ใหม่จากต้นฉบับที่ไม่เปลี่ยนแปลงเสมอ: ไม่ต้องดูแล undo stack เลย และไม่มีทางที่การสลับโหมดต่อเนื่องหลายครั้งจะทำให้คุณภาพของหน้าลดลง สถานการณ์ที่มีหลายหน้าต่างก็ยังคงสอดคล้องกันด้วยเหตุผลเดียวกัน สองมุมมองของเอกสารเดียวกันสามารถรันคนละโหมดได้ เพราะแต่ละมุมมองเป็นเจ้าของสถานะการแสดงผลของตัวเอง ในขณะที่ตัวออบเจกต์เอกสารยังคงใช้ร่วมกันอยู่
เรนเดอร์ก่อน แล้วค่อยแปลงสี
รูปแบบที่รองรับคือการประมวลผล bitmap หลังเรนเดอร์: RenderPage สร้างภาพราสเตอร์ของหน้าขึ้นมาก่อน แล้วค่อยมีขั้นตอนแปลงสี (transform) มาปรับมันอีกที คอมโพเนนต์นี้มีการแปลงสีสามแบบมาให้เป็นการดำเนินการ bitmap แบบ in-place คือ InvertPdfBitmap, DuotonePdfBitmap และ GrayscalePdfBitmap ซึ่งทำให้การสลับโหมดเป็นฟังก์ชันสองขั้นตอนที่สะอาดเรียบร้อย:
function TViewerForm.RenderWithMode(W, H: Integer): TBitmap;
begin
Result := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
case FReadingMode of
rmInverted: InvertPdfBitmap(Result);
rmHighContrast: DuotonePdfBitmap(Result, clBlack, $0000C8FF); // พื้นหลังมืด ข้อความสีเหลืองอำพัน
rmGrayscale: GrayscalePdfBitmap(Result);
end;
// rmNormal ตกผ่านไปโดยไม่ทำอะไร: เอกสารยังคงใช้สีของตัวเองอยู่
end;
มีผลสองอย่างตามมาจากการออกแบบนี้ ข้อแรก ต้นทุนของการแปลงสีแปรผันตามขนาด bitmap ดังนั้นงานนี้ควรอยู่ตรงจุดที่คุณแคชผลลัพธ์การเรนเดอร์ไว้: กรองสี bitmap ที่แคชไว้เพียงครั้งเดียว ไม่ใช่ทุกครั้งที่วาดหน้าจอ ข้อสอง เพราะการแปลงสีทำงานบนราสเตอร์ที่เสร็จสมบูรณ์แล้ว มันจึงส่งผลต่อข้อความ งานเวกเตอร์ ภาพ และ appearance ของคำอธิบายประกอบเหมือนกันหมด ความสม่ำเสมอแบบนี้แหละคือสิ่งที่การกลับสีแบบธรรมดาทำผิดพลาดกับภาพถ่าย นี่คือเหตุผลที่การแปลงสีแบบ duotone เป็นค่าเริ่มต้นที่ดีกว่าสำหรับเอกสารที่มีข้อความเยอะ เพราะมันแมปความสว่าง (luminance) ลงบนช่วงไล่สีจากมืดไปสว่างที่เลือกไว้ แทนที่จะกลับเฉดสี ส่วนการกลับสียังคงมีให้เลือกใช้เป็นทางเลือกที่ชัดเจนสำหรับผู้อ่านที่ต้องการ ขอบตัวอักษรที่คมชัดขึ้นเป็นอีกคันโยกหนึ่งที่แยกต่างหาก ออปชันเรนเดอร์ reNoSmoothText ปิด anti-aliasing ของข้อความตอนเรนเดอร์ และเข้ากันได้ดีกับโหมดคอนทราสต์สูงเมื่อซูมมาก
สองแบบของ grayscale ที่ไม่ตรงกัน
ออปชันเรนเดอร์มี reGrayscale รวมอยู่ด้วย ซึ่งดูเหมือนจะเป็นทางลัดข้ามขั้นตอน post-processing ไปได้ แต่มันไม่ใช่การดำเนินการแบบเดียวกัน:
// ระดับเอนจิน: grayscale ถูกใช้ระหว่างการทำ rasterization
GrayA := Pdf.RenderPage(0, 0, W, H, ro0, [reGrayscale]);
// Post-process: เรนเดอร์เป็นสีก่อน แล้วแปลง bitmap ที่เสร็จแล้ว
GrayB := Pdf.RenderPage(0, 0, W, H);
GrayscalePdfBitmap(GrayB);
ออปชันระดับเอนจินใช้ได้กับเอาต์พุตราสเตอร์ของเนื้อหาภาพ แต่ไม่ครอบคลุมถึงการเติมสีแบบเวกเตอร์หรือสีข้อความ ดังนั้นหน้าที่มีหัวข้อสีสันสดใสอาจได้ภาพถ่ายเป็นสีเทากลับมาแต่หัวข้อยังคงเป็นสีน้ำเงินดื้อ ๆ อยู่ ในขณะที่ GrayscalePdfBitmap บน bitmap ที่เสร็จแล้วจะแปลงทุกอย่างแบบไม่มีเงื่อนไข ออปชันเรนเดอร์ยังคงมีที่ทางของมันอยู่ เมื่อคุณต้องการให้ภาพลดความอิ่มสีแต่ยังคงสีข้อความไว้เป็นสัญญาณ ซึ่งเป็นสิ่งที่ผู้อ่านสายตาเลือนรางบางคนชอบเป็นพิเศษ แต่ถ้าความต้องการเขียนไว้ว่า "หน้าแบบ grayscale" การประมวลผลภายหลังคือเวอร์ชันที่ตอบโจทย์นั้น ไม่ว่าคุณจะเลือกทางไหน ให้จำรูปแบบ overload ทั้งสองแบบของ RenderPage ไว้ในใจ รูปแบบฟังก์ชันจะคืนค่า bitmap ที่ผู้เรียกเป็นเจ้าของและต้องปล่อยเอง (free) ซึ่งเรื่องนี้สำคัญขึ้นทันทีที่ฟิลเตอร์ทำให้จำนวน bitmap ที่เรนเดอร์แล้วค้างอยู่ในหน่วยความจำเพิ่มขึ้นหลายเท่า
พื้นหลัง เครื่องหมายการเลือกข้อความ และกับดักของ PageColor
ไม่ใช่การปรับความสบายตาทุกอย่างที่ต้องเป็นการแปลงสี การเปลี่ยนพื้นหลังหน้ากระดาษสีขาวให้เป็นโทนอุ่นก็มักจะเพียงพอในตัวเองแล้วสำหรับผู้อ่านที่ไวต่อแสงจ้า และมันก็มีพร็อพเพอร์ตี้เฉพาะของตัวเองด้วย แต่พร็อพเพอร์ตี้นี้มีกฎเรื่องขอบเขตที่ทำให้คนพลาดกันได้:
// ส่งผลต่อมุมมองบนหน้าจอเท่านั้น
PdfView.PageColor := $00D9EDF2; // โทนกระดาษสีอุ่นด้านหลังเนื้อหาหน้า
// เอาต์พุตของ RenderPage ไม่สนใจ PageColor; ต้องส่งค่าสีเข้าไปโดยตรง
Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [], $00D9EDF2);
PageColor เปลี่ยนสิ่งที่ TPdfView แสดงผล แต่ bitmap ที่สร้างผ่าน RenderPage ยังคงใช้สีขาวเป็นค่าเริ่มต้น เว้นแต่พารามิเตอร์ Color จะระบุไว้เป็นอย่างอื่น อาการนี้เกิดขึ้นซ้ำ ๆ อย่างสม่ำเสมอ: หน้าจอแสดงหน้ากระดาษที่ปรับโทนสีแล้ว ผู้ใช้ส่งออกหรือพิมพ์ แล้วผลลัพธ์กลับกลายเป็นสีขาวอีกครั้ง ให้จัดเรื่องนี้ไว้ในกลุ่มการตัดสินใจเรื่องนโยบายการส่งออกเดียวกับที่กล่าวไว้ในส่วนแรก
พร็อพเพอร์ตี้สีที่เหลือกำหนดเครื่องหมายซ้อนทับ (overlay): HighlightColor สำหรับผลการค้นหา, SelectionColor สำหรับการเลือกข้อความของผู้ใช้, ReadingWordColor สำหรับเคอร์เซอร์คำที่กำลังอ่านออกเสียง ทุกตัวต้องถูกตรวจสอบซ้ำภายใต้ทุกฟิลเตอร์ที่คุณเปิดให้ใช้ เคอร์เซอร์อ่านสีเหลืองอำพันที่ใช้งานได้ดีบนพื้นขาวจะหายไปหลังกลับสี ส่วนการเลือกข้อความสีฟ้าอ่อนก็จะจมหายไปในพื้นหลังคอนทราสต์สูง ให้ดูแลจานสีของ overlay แยกตามแต่ละโหมด แทนที่จะใช้ชุดเดียวทั่วทั้งระบบ แล้วทดสอบทุกชุดผสมโดยตั้งใจ ฟิลเตอร์บวกกับ text-to-speech เป็นการตั้งค่าปกติสำหรับผู้อ่านที่ฟีเจอร์นี้รองรับ ไม่ใช่กรณีขอบ กลไกของ overlay เองครอบคลุมอยู่ในบทความเรื่องการอ่านเพื่อการช่วยการเข้าถึง
ตัวเลข การตรวจสอบ และคำถามเรื่องการพิมพ์
WCAG 2.1 ทำให้ฟีเจอร์นี้กลายเป็นสิ่งที่วัดผลได้ เกณฑ์ความสำเร็จ 1.4.3 ต้องการอัตราส่วนคอนทราสต์ 4.5:1 สำหรับข้อความเนื้อหา และ 1.4.6 ยกระดับขึ้นเป็น 7:1 สำหรับคอนทราสต์ขั้นสูง ให้สุ่มตรวจโหมดคอนทราสต์สูงของคุณเทียบกับอัตราส่วนเหล่านี้ ด้วยเครื่องมือวิเคราะห์คอนทราสต์ที่รันบนผลลัพธ์ที่เรนเดอร์จริง ข้อความที่วางทับบนภาพและข้อความในฟิลด์ฟอร์มคือจุดที่อัตราส่วนมักจะตกอย่างเงียบ ๆ แม้ข้อความเนื้อหาหลักจะผ่านแล้วก็ตาม
การพิมพ์สมควรมีการตัดสินใจของตัวเองต่างหาก และค่าเริ่มต้นที่ปกป้องได้ในเชิงเหตุผลคือ appearance ของเอกสารเอง โดยมี "พิมพ์ตามที่แสดงผล" เป็นตัวเลือกที่ผู้ใช้เลือกได้อย่างชัดเจน หน้าที่พิมพ์ออกมาเป็นหลักฐานในเวิร์กโฟลว์จำนวนมากกว่าที่ผู้เขียนตัวแสดงผลมักคาดคิดไว้ และสิ่งพิมพ์สัญญาที่กลับสีแล้วก็คือเหตุการณ์ซัพพอร์ตที่มีกลิ่นอายทางกฎหมายติดมาด้วย ยังมีอีกคู่หนึ่งที่สำคัญด้านประสิทธิภาพ: การเรนเดอร์ที่ผ่านฟิลเตอร์เพิ่มงาน bitmap เป็นสองเท่าในทุกการสลับโหมด ดังนั้นอย่านำการแปลงสีไปใช้กับทุกข้อความ paint ให้แคช bitmap ที่ผ่านฟิลเตอร์ไว้แล้วรันการแปลงสีใหม่เฉพาะเมื่อหน้า การซูม หรือโหมดเปลี่ยนแปลงจริง ๆ กลยุทธ์การแคชที่ทำให้เรื่องนี้ทำได้ในต้นทุนต่ำอยู่ในบทความแคชการเรนเดอร์และประสิทธิภาพการซูม
มีอีกเรื่องหนึ่งที่ควรตัดสินใจใน UI ของคุณ ไม่ใช่ในโค้ด: โหมดไหนคือค่าเริ่มต้นที่ถูกต้อง ไม่มีคำตอบเดียวที่ตายตัว ดังนั้นให้เปิดชุดตัวเลือกทั้งหมดแล้วปล่อยให้ผู้อ่านเลือกเอง คอนทราสต์สูงเหมาะกับการอ่านที่มีข้อความเยอะส่วนใหญ่ การกลับสีเหมาะกับผู้อ่านที่ต้องการแบบตัวอักษรสว่างบนพื้นมืดโดยเฉพาะ grayscale ช่วยตัดสัญญาณรบกวนของสี และการปรับโทนพื้นหลังช่วยเรื่องความไวต่อแสงจ้า ให้บันทึกค่าที่เลือกไว้ต่อผู้ใช้แต่ละคน คืนค่านั้นกลับมาตอนเริ่มโปรแกรม และเก็บทางกลับไปโหมดปกติไว้ด้วยการกดคีย์เดียว เพราะผู้อ่านที่หลุดเข้าไปในโหมดที่ตัวเองอ่านไม่ออกต้องการทางออกที่รวดเร็ว
ออปชันเรนเดอร์ การแปลงสี bitmap และพร็อพเพอร์ตี้สีของมุมมองที่ใช้ในบทความนี้มาพร้อมกับ PDFium Component สำหรับ Delphi, C++Builder และ Lazarus/FPC พร้อมซอร์สโค้ดเต็มรูปแบบ เพื่อให้สามารถตรวจสอบหรือขยายการทำงานของการแปลงสีเหล่านี้ได้