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

แปลง XPS และ OpenXPS เป็น PDF ใน Delphi: พิกัดกับแปรง

HotPDF แปลงแพ็กเกจ XPS และ OpenXPS เป็น PDF ภายใน Delphi และ C++Builder โดยไม่ต้องมี print driver มันส่งพิกัด fixed-page ทุกตัวที่ 96 DPI ผ่านเมทริกซ์หน้าแบบพลิกแกน Y ขนาด 0.75 หนึ่งชุด เผยแพร่ VisualBrush แต่ละตัวเป็น Form XObject ที่ใช้ร่วมกันได้ และเปลี่ยนโหมด tile ของ ImageBrush ให้เป็น tiling pattern เนทีฟของ PDF แทนการวาดภาพซ้ำ ๆ กองกัน

สถานการณ์ที่ลากร้านซอฟต์แวร์ฝั่ง Windows ส่วนใหญ่เข้ามาก็น่าเบื่อและหลีกไม่ได้ มีบางอย่างพิมพ์ออก Microsoft XPS Document Writer อยู่แล้ว — รายงาน ERP รุ่นเก่า, แบบฟอร์มที่ลงนามแล้ว, ชุดใบแจ้งยอด — และนโยบาย archive บอกว่าต้องเป็น PDF XPS เป็นฟอร์แมต capture ที่ใช้ได้ดี และเป็นฟอร์แมตที่แย่มากถ้าต้องส่งให้ระบบจัดเก็บเอกสารในอีกสิบปี ไฟล์ spool จึงต้องกลายเป็น PDF หน้าต่อหน้า และทันทีที่เริ่มเขียนตัวแปลง คุณจะค้นพบว่าส่วนที่น่าสนใจไม่ใช่ XML มันคือตอนที่ XPS กับ PDF ไม่เห็นด้วยกันว่าจุดกำเนิดอยู่ตรงไหน หน่วยหนึ่งหน่วยมีค่าเท่าไร และแปรงหนึ่งตัวมีสิทธิ์เป็นอะไรได้บ้าง

จากแพ็กเกจสู่ PDF ในหนึ่งรอบ

จุดเข้าคือ registry ของ document handler ไม่ใช่คลาส XPS พิเศษ THPDFDocumentHandlerRegistry.RegisterStandardHandlers ติดตั้ง handler ของ XPS, EPUB และ CBZ การจำแนกขึ้นกับเนื้อหา แพ็กเกจที่แบก [Content_Types].xml บวกไฟล์ .fpage อย่างน้อยหนึ่งชิ้นได้คะแนน 95 แม้สกุลไฟล์จะโกหก ขณะที่สกุล .xps หรือ .oxps เปล่า ๆ ได้แค่ 10 ลำดับแบบนี้มีผลเมื่อคุณรับไฟล์อัปโหลด เพราะผู้โจมตีที่เปลี่ยนชื่อ EPUB เป็น .xps ไม่ควรเป็นคนชี้ทางให้ pipeline

var
  Handled: IHPDFHandledDocument;
  Info: THPDFDocumentHandlerInfo;
  Options: THPDFDocumentHandlerOptions;
  Registry: THPDFDocumentHandlerRegistry;
  Output: TFileStream;
begin
  Registry := THPDFDocumentHandlerRegistry.Create;
  Output := TFileStream.Create('spool.pdf', fmCreate);
  try
    Registry.RegisterStandardHandlers;
    Options := THPDFDocumentHandlerOptions.Default;
    if not Registry.OpenFromFile('spool.xps', '', Options, Handled, Info) then
      raise Exception.Create('no registered handler recognised the package');
    if Handled.Format = hdfXPS then
      Handled.WritePDF(Output, Options, Info);
    // Info.UnsupportedFeatureCount คือคะแนนตรงไปตรงมาของการแปลงครั้งนี้
  finally
    Output.Free;
    Registry.Free;
  end;
end;

ทุกอย่างในการแปลงถูกกำหนดงบก่อนถูกลงมือ THPDFDocumentHandlerOptions.Default จำกัด archive entry ที่ 10,000 รายการ, ไบต์หลังคลายการบีบอัดที่ 1 GiB, อัตราส่วนการบีบอัดที่ 200, resource ที่ 4,096 และหน้าที่ 10,000 หน้า พร้อม CancellationToken ที่ใส่หรือไม่ใส่ก็ได้ เพื่อให้งานฝั่งเซิร์ฟเวอร์หยุดกลางแพ็กเกจได้ อ่าน Info.UnsupportedFeatureCount ทีหลังและถือว่าค่าไม่ใช่ศูนย์เป็นข้อค้นพบของจริง: HotPDF ตั้งใจนับสิ่งที่มันแมปไม่ได้ แทนการวาดค่าประมาณแล้วเมียงเงียบ ๆ

ทำไมหน้า XPS จึงต้องการเมทริกซ์ แทนการเขียนพิกัดใหม่ทีละตัว

เพราะการเขียนพิกัดใหม่ทำ transform stack หายไปตลอดเส้นทาง FixedPage ของ XPS ถูกระบุในหน่วย 96 DPI โดยจุดกำเนิดอยู่บนซ้ายและ Y โตลงล่าง พื้นที่ผู้ใช้ของ PDF เป็น 72 DPI โดยจุดกำเนิดอยู่ล่างซ้ายและ Y โตขึ้นบน ทางแก้แบบตรงไปตรงมาคือคูณทุกเลขด้วย 0.75 แล้วเอาหน้ากระดาษลบ Y ทุกตัวขณะปล่อยออกไป มันใช้ได้กับ path แบน ๆ หนึ่งอัน แล้วพังลงทันทีที่ RenderTransform, Canvas ซ้อนกัน หรือเมทริกซ์ระดับแปรงเดินเข้ามา เพราะ transform พวกนั้นถูกนิยามในปริภูมิ XPS และการแปลงทีละพิกัดของคุณออกจากปริภูมินั้นไปนานแล้ว HotPDF จึงเก็บ projection ไว้เป็นเมทริกซ์และประกอบมัน HPDFXPSPageMatrix คืนค่าคงตัวของหน้าให้ครั้งเดียวต่อหนึ่งหน้า HPDFMultiplyXPSMatrix ต่อมันกับ transform สะสมของ path แล้วผลลัพธ์ถูกปล่อยออกมาเป็น operator cm เดียวก่อนเรขาคณิต ข้อมูล path จึงถูกเขียนด้วยเลข XPS ดิบที่ไม่ถูกแตะ ซึ่งก็เป็นเหตุผลเดียวกันที่ไวยากรณ์เรขาคณิตย่อสามารถใช้ parser แบบมีขอบเขตตัวเดียวกับข้อมูล SVG path ได้ — มีเพียง token fill-rule นำหน้า F0 หรือ F1 ที่ adapter ของ XPS ต้องจัดการเอง ถ้าคุณเดินตรรกะเดียวกันนี้มากับ การนำเข้าเวกเตอร์ EMF และ WMF แล้ว รูปร่างของข้อโต้แย้งจะคุ้นตา: ฟอร์แมตนำเข้าถูกแปลงด้วยเมทริกซ์ ไม่ใช่ด้วยการคิดเลขที่พิกัดใบไม้

HotPDF ประกอบเมทริกซ์หน้าคงที่ของ XPS เข้ากับ transform สะสมของ path เพื่อให้ปริภูมิพิกัด XPS แบบ 96 DPI บนซ้ายเดินทางไปถึงพื้นที่ผู้ใช้ PDF แบบ 72 DPI ล่างซ้าย โดยปล่อยออกมาเป็น operator cm เดียวต่อ visual หนึ่งตัว
projection คงอยู่ในรูปเมทริกซ์และถูกประกอบกับ transform ซ้อนทุกชั้น ข้อมูล path จึงเขียนด้วยเลข XPS ดิบได้
function HPDFXPSPageMatrix(PageHeight: Double): THPDFXPSMatrix;
begin
  Result.A :=  0.75;       // หน่วย XPS 96 DPI ไปยัง PDF point 72 DPI
  Result.B :=  0;
  Result.C :=  0;
  Result.D := -0.75;       // Y ของ XPS โตลงล่าง, Y ของ PDF โตขึ้นบน
  Result.E :=  0;
  Result.F := PageHeight;  // ความสูงหน้า PDF หน่วย point
end;

// หนึ่ง CTM ที่ประกอบแล้วต่อ visual หนึ่งตัว ปล่อยออกมาก่อน path operator ใด ๆ
Effective := HPDFMultiplyXPSMatrix(HPDFXPSPageMatrix(PageHeightPDF), PathMatrix);
Page.AppendRawContent(HPDFXPSMatrixOperator(Effective));

ใช้ VisualBrush ซ้ำโดยไม่ต้องวาดสองรอบได้อย่างไร

VisualBrush วาด visual tree อิสระ — ลูกเป็น Canvas, Path และ Glyphs — ลงบนพื้นที่หนึ่ง โดยวาดซ้ำครอบทั้งพื้นที่ก็ได้ HotPDF คอมไพล์ visual นั้นครั้งเดียวเป็น PDF Form XObject แล้วจึงเอาไปวาง ซึ่งเป็นกลยุทธ์ resource เดียวกับที่อธิบายไว้ในบทความ การนำเข้า SVG ผ่าน Form XObject รายละเอียดสองจุดตัดสินว่ามันทำงานไหม หนึ่ง visual ต้องถูกเดินในฐานะลูก XML โดยตรง การสแกนแบนหา element ที่ควร tile จะดึง visual ซ้อนขึ้นไปถึงระดับหน้าและทำลายทั้งขอบเขต resource และลำดับการวาดไปพร้อมกัน สอง เนื้อหาถูกจับด้วยเมทริกซ์หน้า XPS ไปยัง PDF ที่ใช้แล้ว การเผยแพร่ Form จึงต้องคูณด้วยอินเวอร์สของเมทริกซ์นั้น มิฉะนั้นการวางทุกครั้งจะใช้สเกล 0.75 กับการพลิก Y ซ้ำอีกรอบ Form ต้องเป็นเจ้าของ resource ของตัวเองด้วย: HotPDF คัดลอกเฉพาะฟอนต์, XObject, pattern, ExtGState และพื้นที่สีที่ content stream ที่ถูกจับอ้างอิงจริง การโคลน dictionary resource ของทั้งหน้าจะลาก Form ที่กำลังลงทะเบียนเข้าไปในกราฟ resource ของตัวเองแล้วก่อวงจรขึ้นมา ฟอนต์คงอยู่ใน dictionary แบบ direct บนหน้ากระดาษปกติและถูกเลื่อนขึ้นเป็น dictionary indirect ที่ใช้ร่วมกันเฉพาะเมื่อเนื้อหาที่ถูกจับมี Tf อยู่จริง เอกสารที่ไม่มี visual ใช้ซ้ำจึงไม่ต้องจ่ายค่าเครื่องจักรนี้ จดขอบเขตของสเปกหนึ่งข้อไว้ก่อนไปฟ้องบั๊ก: หมวด 13.4 ของ ECMA-388 กำหนดให้ ViewboxUnits และ ViewportUnits ของ VisualBrush เป็น Absolute ทั้งคู่ หน่วยแบบ relative จึงไม่ใช่ฟีเจอร์ที่หายไป — มันคืออินพุตที่ผิดสเปก และ HotPDF ปฏิเสธจะคิดความหมายพิกัดขึ้นมาเองให้พวกมัน

HotPDF คอมไพล์ visual tree ของ XPS VisualBrush ครั้งเดียวเป็น PDF Form XObject เผยแพร่ผ่านอินเวอร์สของเมทริกซ์หน้าคงที่เพื่อให้การวางไม่ใช้สเกลซ้ำ และคัดลอกเฉพาะ resource ที่เนื้อหาที่ถูกจับอ้างอิงจริง
เนื้อหาถูกจับพร้อมเมทริกซ์หน้าที่ใช้แล้ว Form จึงถูกเผยแพร่ผ่านอินเวอร์สของมัน และแบกเฉพาะ resource ที่ content stream ของตัวเองอ้างอิง

ImageBrush แบบ tiling: สี่โหมด สี่ขนาดเซลล์

โหมด tile ของ XPS ถูกแมปลงบน tiling pattern ของ PDF จากหมวด 8.7.3 ของ ISO 32000-1 แทนการถูกคลี่ออกเป็นการวางภาพซ้ำคลุมพื้นที่ทั้งหมด ซึ่งทำให้ขนาดเอาต์พุตกับเวลาแปลงไม่ผูกกับว่าแปรงจะคลุมหน้ากระดาษมากแค่ไหน การแมปเป็นเรื่องเชิงกลพอเห็นโครงแล้ว: การสะท้อนถูกแสดงด้วยการวางแบบพับกระจกไว้ในเซลล์ pattern เดียวแล้วขยายเซลล์ให้พอดี

  • Tile — การวางหนึ่งครั้ง เซลล์คงขนาด viewport 1×1
  • FlipX — การวางสองครั้ง เซลล์ถูกทำให้กว้างเป็น 2×1
  • FlipY — การวางสองครั้ง เซลล์ถูกทำให้สูงเป็น 1×2
  • FlipXY — การวางสี่ครั้ง เซลล์ถูกขยายเป็น 2×2

การวางแต่ละครั้งแบก clip rectangle ของตัวเอง เพราะการแมป Viewbox ที่ล้นออกจากเซลล์ย่อยจะเลอะเข้าการสะท้อนหน้าข้าง ส่วน /Matrix ของ pattern คือจุดที่จับคนหลงทาง tiling pattern ถูกยึดกับพื้นที่ผู้ใช้ default ของ content stream พ่อแม่ของมัน ไม่ใช่กับ graphics state ขณะที่ pattern ถูกเลือก เมทริกซ์จึงต้องประกอบทั้งสามชั้นอย่างชัดเจน — projection ระดับ fixed-page, transform ของ Path และ Transform ระดับแปรง — แทนการโอนความหวังไว้กับ CTM รอบตัว HotPDF ตรวจก่อนจองด้วย: RegisterImageTilingPattern จำกัด pattern หนึ่งอันที่ 1,024 การวาง และปฏิเสธ clip ที่ degenerate, เมทริกซ์ที่กลับด้านไม่ได้ และ index ภาพที่ผิด ถ้าอยากได้โมเดลฝั่ง PDF ทั่วไปที่รองรับสิ่งนี้ tiling pattern กับ Pattern colour space อธิบาย operator เบื้องหลังให้ครบ

// projection ระดับ fixed-page พับเข้าเมทริกซ์ pattern ก่อน แล้วจึงฝั่ง brush-local
PatternMatrix := HPDFMatFromOps( 0.75 * PathMatrix.A, -0.75 * PathMatrix.B,
                                 0.75 * PathMatrix.C, -0.75 * PathMatrix.D,
                                 0.75 * PathMatrix.E,
                                 PageHeight - 0.75 * PathMatrix.F);
if Brush.HasTransform then
  PatternMatrix := HPDFMatMul(PatternMatrix, BrushMatrix);

PatternName := Document.RegisterImageTilingPattern(Resource.ImageIndex,
  Brush.Viewport.Left, Brush.Viewport.Top,
  Brush.Viewport.Left + CellWidth, Brush.Viewport.Top + CellHeight,
  CellWidth, CellHeight, Placements, pttNoDistortion, PatternMatrix);

อะไรเกิดขึ้นเมื่อ gradient แบบเรเดียลไม่ใช่วงกลม

XPS นิยาม RadialGradientBrush ด้วย GradientOrigin, Center, RadiusX และ RadiusY แปรงจึงเป็นวงรี Shading type 3 ของ PDF ตามหมวด 8.7.4.5.4 ของ ISO 32000-1 เบลนด์ระหว่างวงกลมสองวงและไม่มีทางแสดงวงรีได้โดยตรง การเฉลี่ยรัศมีสองตัวให้เหลือเลขเดียวคือทางลัดที่ชวนหลงใหลและผิดเห็นได้ชัดบนแปรงที่ไม่ค่อยกลม HotPDF ย้ายปัญหาไปที่ระบบพิกัดแทน: มันสเกล Y ด้วย RadiusY / RadiusX ลงทะเบียน shading วงกลมแท้ในปริภูมิที่ถูกสเกลแล้ว เลือก pattern และปล่อยสเกลย้อนกลับออกไปทันทีเพื่อให้เรขาคณิต path ที่เขียนต่อจากนั้นยังอยู่ในปริภูมิผู้ใช้ XPS เดิม

ScaleY := Brush.RadiusY / Brush.RadiusX;
PatternName := Document.RegisterMultiStopRadialGradient(
  Brush.StartX, Brush.StartY / ScaleY, 0,
  Brush.EndX,   Brush.EndY   / ScaleY, Brush.RadiusX,
  StopPositions, StopColours, 3);

if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(ScaleY) + ' 0 0 cm'#10);
Page.SetFillPattern(PatternName);   // pattern จับ CTM ไว้ตรงนี้เอง
if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(1 / ScaleY) + ' 0 0 cm'#10);

ลำดับในตัวอย่างนั้นคือทั้งกลเม็ด และมันไม่ใช่เรื่องสไตล์ Shading pattern ของ PDF จับเมทริกซ์การแปลงปัจจุบัน ณ ขณะที่มันถูกเลือกเป็นสีปัจจุบัน สเกลชั่วคราวจึงต้องถูกปล่อยออกมาก่อน SetFillPattern หรือ SetStrokePattern และสเกลย้อนกลับต้องมาตามหลังการเลือกแต่ก่อน operator ของ path ลำดับผิดทางใดทางหนึ่ง คุณจะได้ gradient ที่เรนเดอร์ถูกบน path แรกแล้วเหลื่อมไปทุก path ถัดไป เงื่อนไขที่เกี่ยวข้องอีกข้อใช้กับโหมดพิกัดแบบ relative: RadiusX กับ RadiusY ต้องถูก resolve เทียบกับความกว้างกับความสูงของ path แยกกัน เพราะการสเกลทั้งคู่ด้วยความยาวด้านเดียวเปลี่ยนอัตราส่วนภาพของวงรีเงียบ ๆ บน path ใด ๆ ที่ไม่ใช่สี่เหลี่ยมจัตุรัส

HotPDF แมป XPS RadialGradientBrush รูปวงรีลงบน PDF shading type 3 ด้วยการสเกลแกน Y ลงทะเบียน shading วงกลมในปริภูมิที่ถูกสเกล และปล่อยสเกลย้อนกลับเฉพาะหลัง pattern จับเมทริกซ์การแปลงปัจจุบันไปแล้ว
วงรีถูกดูดกลืนโดยระบบพิกัดแทนที่จะเป็นโดย shading และสเกลชั่วคราวต้องคลุมการเลือก pattern ในลำดับที่เป๊ะแบบนั้น

จุดที่การแปลงพูดตรงเรื่องขีดจำกัดของตัวเอง

มี construct ของ XPS ที่ถูกแปลงอย่างคร่าว ๆ และบางอย่างที่ไม่ถูกแปลงเลย การตัดสินใจเชิงออกแบบตลอดทางคือนับพวกมัน ไม่ใช่แต่งให้เหมือน ส่วน TIFF กับ JPEG XR ถูก rasterise ผ่าน WIC และไม่ให้คำสัญญาใดเกี่ยวกับ alpha ที่ถูกเก็บรักษา ขณะที่ PNG ที่มี alpha channel ถูกต้องถูกแยกเป็นภาพพื้นฐานบวก /SMask ขนาดโดยกำเนิดของภาพถูก derive เป็น pixel * 96 / DPI โดยอ่าน pHYs ของ PNG หรือความหนาแน่น JFIF ของ JPEG ก่อนแล้วจึง fallback เป็น 96 DPI หัวความหนาแน่นที่เสียจึงตกที่ขนาดที่คาดเดาได้ ไม่ใช่ขนาดที่พลิกผัน matrix resource ที่ resolve ไม่ได้, relative transform ที่ไม่มาตรฐาน, ColorConvertedBitmap, gradient spread mode ที่ไม่รองรับ และเรขาคณิตที่ผิดรูปล้วนเพิ่ม UnsupportedFeatureCount และอินพุตที่ผิดรูปจบด้วย fail closed แทนการเสื่อมลงเป็นภาพวาดที่ต่างไปเงียบ ๆ

นั่นคือท่าทีที่มีประโยชน์ของตัวแปลงสำหรับงานเก็บถาวร: การแปลงที่ค่าประมาณอย่างเงียบ ๆ ย่อมแย่กว่าตัวที่บอกคุณว่ามันแมปสี่ element ตัวไหนไม่ได้ เพราะมีเพียงตัวหลังที่ให้สิ่งใดไว้ตรวจก่อนเอกสารจะถูกปิดผนึกเข้าระบบจัดเก็บเอกสาร ถ้าคุณกำลังประเมินการแปลง XPS และ OpenXPS ร่วมกับ pipeline เอกสารส่วนที่เหลือ — การจัดองค์ประกอบหน้า, ฟอนต์, การลงนาม, เอาต์พุต PDF/A — หน้า HotPDF Delphi PDF component ลิสต์ชุดฟีเจอร์ครบชุดและเวอร์ชัน Delphi กับ C++Builder ที่รองรับ