Technical Article

กราฟิกเวกเตอร์ใน PDF ด้วย Delphi: เส้นทางและไล่โทนสี

โค้ด Delphi ส่วนใหญ่ที่เกี่ยวข้องกับ PDF มักมองว่ารูปแบบไฟล์เป็นเพียงที่บรรจุสองสิ่งนี้: สตริงข้อความและบิตแมปที่จัดวางเอาไว้ มุมมองนั้นก็ถูกต้องในระดับหนึ่ง แต่มันทำให้ความสามารถที่ทรงพลังที่สุดของรูปแบบไฟล์ไม่ได้ถูกนำมาใช้งาน หน้าเอกสาร PDF คือผืนผ้าใบแบบเวกเตอร์ 2 มิติที่เป็นอิสระต่อความละเอียดของภาพ ซึ่งสร้างขึ้นบนโมเดลการประมวลผลภาพแบบเดียวกับ PostScript มันสามารถวาดเส้น เส้นโค้ง พื้นที่เติมสี การไล่ระดับสี และรูปแบบการไล่โทนสีซ้ำๆ ทั้งหมดนี้ทำเป็นเวกเตอร์ที่ยังคงความคมชัดในทุกระดับการซูมและพิมพ์ที่ความละเอียดเต็มของอุปกรณ์แสดงผล หากคุณกำลังวาดโลโก้ แผนภูมิ ลายน้ำ หรือขอบใบรับรอง เส้นทางเวกเตอร์มักเป็นโครงสร้างพื้นฐานที่ถูกต้องเสมอ และมีขนาดที่เล็กกว่ารวมถึงคมชัดกว่ารูปภาพแบบราสเตอร์ที่โปรแกรมจำนวนมากนิยมใช้แทน

บทความนี้จะนำทางคุณไปรู้จักโมเดลเวกเตอร์ตามที่มาตรฐาน ISO 32000-1 กำหนดไว้ และแสดงการเรียกใช้ฟังก์ชันใน PDFlibPas ที่สอดคล้องกัน วัตถุประสงค์คือเพื่อให้ข้อกำหนดนี้สามารถจับต้องได้ชัดเจน เนื่องจากโครงสร้างของ API แผนผังมีความสอดคล้องกันอย่างใกล้ชิด และการเข้าใจส่วนหนึ่งจะช่วยให้คุณเรียนรู้อีกส่วนหนึ่งได้

หน้าเอกสารคือกลไกการสร้างเส้นทาง

มาตรฐาน ISO 32000-1 §8.5 อธิบายเกี่ยวกับการจัดการกราฟิกเป็นสองขั้นตอนที่ไม่มีวันซ้อนทับกัน ขั้นแรกคือการสร้างเส้นทาง (path) ซึ่งเป็นรูปเรขาคณิตล้วนๆ ที่ไม่มีผลการแสดงผลให้เห็นได้ในทันที จากนั้นคุณระบายสีเส้นทางนั้นในการดำเนินการเดียวเพื่อวาดโครงร่าง เติมสีภายใน หรือทำทั้งสองอย่าง ไม่มีสิ่งใดปรากฏบนหน้ากระดาษระหว่างขั้นตอนการสร้าง เส้นทางเป็นเพียงลำดับนามธรรมของจุดและกลุ่มส่วนที่จัดเก็บไว้ในสถานะกราฟิกจนกว่าตัวดำเนินการระบายสีจะเรียกใช้งาน ซึ่ง ณ จุดนั้นเส้นทางจะถูกแสดงผลและถูกทิ้งไป

เส้นทางประกอบด้วยเส้นทางย่อย (subpaths) ตั้งแต่หนึ่งเส้นทางขึ้นไป เส้นทางย่อยเริ่มต้นที่จุดหนึ่งและยาวขึ้นโดยการเพิ่มเซกเมนต์: เส้นตรง เส้นโค้งเบซิเยร์แบบลูกบาศก์ และในบางแพลตฟอร์มก็รวมถึงรูปสี่เหลี่ยมผืนผ้าทั้งหมดที่เพิ่มเป็นเส้นทางย่อยที่ปิดในตัวเอง ใน PDFlibPas คุณเปิดเส้นทางด้วย StartPath ซึ่งจะตั้งค่าจุดเริ่มต้น จากนั้นขยายเส้นทางด้วย AddLineToPath และ AddCurveToPath การเรียกแต่ละครั้งจะขยับจุดปัจจุบันไปข้างหน้าโดยปริยาย ดังนั้นเซกเมนต์ถัดไปจะเริ่มจากจุดที่เซกเมนต์ล่าสุดสิ้นสุดลง ClosePath จะวาดเซกเมนต์ตรงสุดท้ายกลับไปยังจุดเริ่มต้นของเส้นทางย่อย ซึ่งมีความสำคัญต่อการวาดเส้นขอบเพราะจะช่วยสร้างจุดต่อเชื่อมของเส้นจริงที่จุดยอดปิด แทนที่จะเป็นขอบจุดสิ้นสุดที่แยกจากกันสองจุด

// A closed quadrilateral, stroked then filled
PDF.SetLineColor(0, 0, 0);
PDF.SetFillColor(0.6, 0.8, 1.0);
PDF.SetLineWidth(1.5);

PDF.StartPath(150, 100);           // open the path at the first vertex
PDF.AddLineToPath(220, 140);
PDF.AddLineToPath(180, 210);
PDF.AddLineToPath(110, 170);
PDF.ClosePath;                     // straight segment back to (150, 100)
PDF.DrawPath(2);                   // 2 = fill and stroke; path is consumed

เส้นโค้งจะใช้ AddCurveToPath ซึ่งรับจุดควบคุมเบซิเยร์สองจุดและจุดสิ้นสุดหนึ่งจุด: AddCurveToPath(CtAX, CtAY, CtBX, CtBY, EndX, EndY) เส้นโค้งจะวิ่งจากจุดปัจจุบันไปยัง (EndX, EndY) โดยถูกดึงเข้าหาจุดควบคุมทั้งสองในระหว่างทาง ส่วนส่วนโค้งวงกลมจะใช้ได้ผ่าน AddArcToPath(CenterX, CenterY, TotalAngle) โดยที่รัศมีจะคิดจากระยะห่างระหว่างจุดปัจจุบันกับจุดศูนย์กลาง และเอ็นจินจะส่งออกส่วนโค้งเป็นชุดของส่วนของเบซิเยร์ สำหรับรูปสี่เหลี่ยมผืนผ้าจะมีทางลัดคือ AddBoxToPath(Left, Top, Width, Height) ซึ่งจะเพิ่มรูปสี่เหลี่ยมผืนผ้าปิดที่สมบูรณ์เป็นเส้นทางย่อยในตัวเองโดยไม่ต้องมี StartPath นำหน้า

กฎการเติมสีสองข้อ และทำไมพวกมันจึงเห็นต่างกัน

เมื่อคุณเติมสีลงในเส้นทางที่ตัดกันเองหรือมีวงรอบภายใน ตัวแสดงผลจำเป็นต้องมีกฎสำหรับตัดสินว่าพื้นที่ใดอยู่ภายในรูปทรงและพื้นที่ใดเป็นช่องว่าง มาตรฐาน ISO 32000-1 §8.5.3.3 ได้กำหนดไว้สองกฎ และอาจระบายสีรูปเรขาคณิตเดียวกันแตกต่างกัน กฎการพันรอบที่ไม่เป็นศูนย์ (nonzero winding rule) จะนับจำนวนการตัดกันแบบมีเครื่องหมายของรังสีที่ลากจากจุดทดสอบไปยังอินฟินิตี้ โดยเพิ่มหนึ่งสำหรับการตัดจากซ้ายไปขวาแต่ละเส้น และลบหนึ่งสำหรับการตัดในทิศทางตรงกันข้าม จุดนั้นจะถือว่าอยู่ภายในเมื่อยอดรวมไม่เป็นศูนย์ ส่วนกฎเลขคู่-เลขคี่ (even-odd rule) จะละเว้นทิศทางและนับเพียงจำนวนครั้งที่ตัดกัน โดยระบุว่าจุดนั้นอยู่ภายในเมื่อผลรวมเป็นเลขคี่

กรณีคลาสสิกที่พวกมันมีความเห็นแตกต่างกันคือรูปทรงที่มีรู เช่น โดนัทหรือแหวนรอง วาดขอบเขตภายนอกและขอบเขตภายในไว้ด้านใน ภายใต้กฎเลขคู่-เลขคี่ ห่วงด้านในจะเจาะรูเสมอ เนื่องจากจุดใดๆ ระหว่างสองขอบเขตจะถูกตัดกันหนึ่งครั้ง และจุดใดๆ ภายในห่วงด้านในจะถูกตัดกันสองครั้ง ภายใต้กฎการพันรอบที่ไม่เป็นศูนย์ รูจะปรากฏเฉพาะในกรณีที่ห่วงด้านในพันไปในทิศทางตรงกันข้ามกับห่วงด้านนอก หากพันไปในทิศทางเดียวกัน การพันรอบจะเสริมกันแทนที่จะหักล้าง และพื้นที่ด้านในจะถูกเติมสีทึบ ดาวห้าแฉกที่วาดเป็นเค้าโครงเส้นเดี่ยวตัดกันเองก็แสดงความแตกต่างแบบเดียวกันนี้: กฎเลขคู่-เลขคี่จะปล่อยให้รูปห้าเหลี่ยมตรงกลางว่างเปล่า ในขณะที่การพันรอบที่ไม่เป็นศูนย์จะเติมสีจนเต็ม

PDFlibPas เลือกกฎตามฟังก์ชันที่คุณเรียกใช้เพื่อระบายสี ไม่ใช่ตามแฟล็ก DrawPath จะเติมสีด้วยกฎการพันรอบที่ไม่เป็นศูนย์ ส่วน DrawPathEvenOdd จะเติมสีด้วยกฎเลขคู่-เลขคี่ ทั้งสองฟังก์ชันรับโหมดจำนวนเต็มเดียวกัน: 0 สำหรับการวาดเส้นขอบเท่านั้น, 1 สำหรับการเติมสีเท่านั้น และ 2 สำหรับการเติมสีและวาดเส้นขอบ กฎเลขคู่-เลขคี่เป็นเครื่องมือที่ง่ายกว่าสำหรับการเจาะช่องว่าง เพราะมันไม่บังคับให้คุณต้องจัดการกับทิศทางของเส้นทางย่อย

// Same two boxes, two fill rules, two different results.
// Nonzero winding: both boxes wind the same way, so the inner one
// does NOT cut a hole and the whole outer box fills solid.
PDF.SetFillColor(0.2, 0.4, 0.8);
PDF.AddBoxToPath(100, 100, 200, 120);   // outer
PDF.AddBoxToPath(140, 130, 120,  60);   // inner
PDF.DrawPath(1);                         // 1 = fill, nonzero winding

// Even-odd: the inner box is crossed an even number of times,
// so it punches a clean rectangular hole through the outer box.
PDF.SetFillColor(0.2, 0.4, 0.8);
PDF.AddBoxToPath(100, 300, 200, 120);   // outer
PDF.AddBoxToPath(140, 330, 120,  60);   // inner cut-out
PDF.DrawPathEvenOdd(1);                  // 1 = fill, even-odd

การไล่โทนสีตามแนวแกนจะเปลี่ยนสีตามเส้นตรง

สีเติมแบบแบนราบคือการใช้ค่าสีเดียวทั่วทั้งพื้นที่ ส่วนการไล่โทนสีจะเปลี่ยนสีอย่างต่อเนื่อง และประเภทที่ง่ายที่สุดคือการไล่โทนสีตามแนวแกน (axial) หรือการไล่โทนสีเชิงเส้น (linear) มาตรฐาน ISO 32000-1 §8.7.4.5 กำหนดให้เป็นประเภทการแรเงาตามแนวแกนแบบที่ 2 (Type 2 axial shading) โดยคุณระบุจุดสองจุดที่กำหนดแกน สีเริ่มต้นที่จุดแรก และสีสิ้นสุดที่จุดที่สอง จากนั้นตัวแสดงผลจะทำการสอดแทรกสีตามแนวแกนนั้น ทุกจุดในพื้นที่ที่เติมสีจะได้สีจากการฉายแนวตั้งฉากลงบนแกน ดังนั้นสีจะไล่เป็นแถบที่ตั้งฉากกับเส้นตรงระหว่างจุดสองจุดนั้น

ใน PDFlibPas การไล่โทนสีเป็นทรัพยากรเอกสารที่มีชื่อซึ่งคุณสร้างขึ้นเพียงครั้งเดียว จากนั้นเลือกใช้เป็นการระบายสีที่ใช้งานอยู่ NewRGBAxialShader จะทำหน้าที่ลงทะเบียน รูปแบบของฟังก์ชันคือ NewRGBAxialShader(ShaderName, StartX, StartY, StartRed, StartGreen, StartBlue, EndX, EndY, EndRed, EndGreen, EndBlue, Extend): จุดสิ้นสุดของสองแกน, กลุ่มสี RGB สามค่าในแต่ละปลายด้านเป็นค่าในช่วง 0 ถึง 1 และแฟล็ก Extend เมื่อตั้งค่า Extend เป็น 1 สีปลายทางจะแผ่ออกไปเป็นสีทึบพ้นจุดสิ้นสุดของแกน ซึ่งมักจะเป็นสิ่งที่คุณต้องการเพื่อป้องกันไม่ให้มุมของพื้นที่ภายนอกแกนไม่มีการระบายสี ส่วนค่า 0 จะปล่อยให้พื้นที่นั้นว่างเปล่า เมื่อสร้างตัวแรเงา (shader) แล้ว คุณสามารถเชื่อมโยงมันด้วย SetFillShader สำหรับพื้นที่ที่เติมสี, SetLineShader สำหรับเส้นขอบ หรือ SetTextShader สำหรับข้อความ การเชื่อมโยงนี้จะยังมีผลอยู่สำหรับการเรียกใช้วาดภาพที่ตามมา ดังนั้นเส้นทางที่คุณระบายสีถัดไปจะใช้สีไล่ระดับแทนที่จะเป็นสีแบนๆ

// Define a vertical gradient once: blue at the bottom to white at the top.
PDF.NewRGBAxialShader('panelGrad',
  0, 100,   0.10, 0.25, 0.55,    // start point and start RGB
  0, 260,   1.00, 1.00, 1.00,    // end point and end RGB
  1);                            // 1 = extend ends as solid color

// Select the gradient as the fill, then paint a rectangle with it.
PDF.SetFillShader('panelGrad');
PDF.AddBoxToPath(80, 100, 300, 160);
PDF.DrawPath(1);                 // 1 = fill, now filled by the shader

ลวดลายปูกระเบื้อง (Tiling patterns) จะทำซ้ำองค์ประกอบ

ในขณะที่การไล่ระดับสีจะเกลี่ยสีเดียวอย่างราบรื่น ลวดลายปูกระเบื้องจะทำซ้ำผลงานศิลปะชิ้นเล็กๆ ทั่วพื้นที่ มาตรฐาน ISO 32000-1 §8.7.3.1 กำหนดให้ลวดลายปูกระเบื้องเป็นเซลล์ลวดลาย (pattern cell) ซึ่งเป็นชิ้นเนื้อหาที่เป็นอิสระ โดยตัวแสดงผลจะทำซ้ำบนกริดที่คงที่เพื่อปูทับพื้นที่ที่กำลังระบายสี วิธีนี้คือวิธีที่คุณใช้สร้างเส้นลวดลายสำหรับเติมในงานวิศวกรรม ลายแบรนด์ที่ทำซ้ำๆ ด้านหลังส่วนหัว หรือพื้นผิวพื้นหลังที่ยังคงความคมชัดของเวกเตอร์และแทบไม่มีผลต่อขนาดไฟล์เลยไม่ว่าพื้นที่การใช้งานจะใหญ่เพียงใด เนื่องจากเซลล์ลวดลายนั้นถูกจัดเก็บไว้เพียงครั้งเดียวและถูกนำไปอ้างอิงใช้งานได้ในทุกๆ ที่

PDFlibPas สร้างเซลล์ลวดลายจากเนื้อหาหน้ากระดาษที่แคปเจอร์มา คุณแคปเจอร์หน้าหรือพื้นที่โดยใช้ CapturePage แล้วแปลงข้อมูลที่แคปเจอร์ให้เป็นลวดลายที่มีชื่อด้วย NewTilingPatternFromCapturedPage(PatternName, CaptureID) และเลือกใช้ลวดลายนั้นสำหรับการเติมสีปัจจุบันด้วย SetFillTilingPattern(PatternName) จากจุดนั้นเป็นต้นไป เส้นทางใดๆ ที่คุณเติมสีจะถูกระบายด้วยเซลล์ที่ทำซ้ำแทนที่จะเป็นสีแบนๆ ซึ่งทำงานเหมือนกับการเติมสีตัวแรเงาทุกประการแต่ใช้เซลล์ปูกระเบื้องเป็นแหล่งสี ลำดับการทำงานนี้จะมีความซับซ้อนมากกว่าการเรียกใช้งานบรรทัดเดียว ดังนั้นหากไม่คุ้นเคยกับขั้นตอนการแคปเจอร์ ให้มองว่ากระบวนการสร้างลวดลายแบ่งออกเป็นสองขั้นตอน: สร้างเซลล์ที่แคปเจอร์ก่อน จากนั้นจึงผูกมันสำหรับการเติมสีตามชื่อก่อนที่จะเริ่มวาดพื้นที่ที่คุณต้องการปูสีซ้ำ

การรวมโครงสร้างพื้นฐานเข้าด้วยกัน

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

// 1. A filled Bezier shape (nonzero winding).
PDF.SetFillColor(0.85, 0.30, 0.25);
PDF.StartPath(120, 480);
PDF.AddCurveToPath(160, 560, 240, 560, 280, 480);   // top lobe
PDF.AddCurveToPath(240, 420, 160, 420, 120, 480);   // bottom lobe
PDF.ClosePath;
PDF.DrawPath(1);                                     // 1 = fill

// 2. The same outline, plus an inner loop, filled even-odd to show a hole.
PDF.SetFillColor(0.85, 0.30, 0.25);
PDF.StartPath(120, 300);
PDF.AddCurveToPath(160, 380, 240, 380, 280, 300);
PDF.AddCurveToPath(240, 240, 160, 240, 120, 300);
PDF.ClosePath;
PDF.MovePath(180, 300);                              // new subpath: the hole
PDF.AddArcToPath(200, 300, 360);                     // a full circle
PDF.ClosePath;
PDF.DrawPathEvenOdd(1);                              // hole is punched out

// 3. A rectangle filled with an axial gradient.
PDF.NewRGBAxialShader('footerGrad',
  60, 100,  0.95, 0.55, 0.10,
  60, 200,  0.20, 0.10, 0.40,
  1);
PDF.SetFillShader('footerGrad');
PDF.AddBoxToPath(60, 100, 340, 100);
PDF.DrawPath(1);

รายละเอียดสองประการที่ควรจดจำไว้ การเรียกใช้งานระบายสีจะเป็นตัวตัดสินกฎการเติมสี ดังนั้นการเลือกระหว่าง DrawPath และ DrawPathEvenOdd คือการเลือกระหว่างกฎการพันรอบที่ไม่เป็นศูนย์กับกฎเลขคู่-เลขคี่ และสำหรับรูปทรงที่มีช่องว่าง กฎเลขคู่-เลขคี่จะช่วยให้คุณไม่ต้องกังวลกับการกำหนดทิศทางของเส้นทางย่อย และสถานะกราฟิกจะถูกนำมาใช้งานในขณะที่คุณทำการระบายสี: ให้ตั้งค่าสี ความหนาของเส้น และการเชื่อมโยงตัวแรเงาให้เรียบร้อยก่อนการเรียกใช้งานระบายสี เนื่องจากนั่นคือสถานะที่เอ็นจินจะอ่านข้อมูลไปใช้ สร้างเส้นทางก่อน ตั้งค่าสถานะ แล้วระบายสีสุดท้าย แล้วแบบจำลองเวกเตอร์จะทำงานได้อย่างถูกต้องแม่นยำในทุกครั้ง

จากจุดนี้ ขั้นตอนถัดไปที่น่าสนใจคือการอ่านข้อมูลเวกเตอร์และข้อความย้อนกลับออกมาจากเอกสารที่มีอยู่เดิม ซึ่งครอบคลุมอยู่ในบทความของเราเกี่ยวกับการดึงข้อมูลข้อความ รูปภาพ และฟอนต์ และการเรนเดอร์โมเดลการวาดรูปเดียวกันไปยังบริบทของอุปกรณ์ Windows สำหรับการดูตัวอย่างบนจอและการพิมพ์ ซึ่งอธิบายไว้ในคู่มือการแสดงผลและการพิมพ์ตัวอย่าง การเรียกใช้เส้นทาง ตัวแรเงา และลวดลายที่อธิบายไว้ในที่นี้จัดส่งให้เป็นส่วนหนึ่งของคลัง PDF สำหรับ Delphi ควบคู่ไปกับ API ข้อความ รูปภาพ ฟอร์ม และลายเซ็นที่มีอธิบายในส่วนอื่นของบทความส่วนอื่นๆ ของบล็อกนี้