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

การ Resolve OPC Relationship ของ XLSX ใน Delphi Parser

xlsx ที่ถูกต้องไม่จำเป็นต้องมี xl/worksheets/sheet1.xml เลย HotXLS ซึ่งเป็น native Excel spreadsheet component สำหรับ Delphi และ C++Builder ค้นหาทุก part ผ่าน OPC relationship graph แทนที่จะเดาชื่อ เพราะ ISO/IEC 29500-2 รับประกันแค่ว่า part เข้าถึงได้จาก _rels/.rels เท่านั้น ไม่เคยรับประกันว่ามันอยู่ที่ path ตามธรรมเนียมเลย

ทำไม parser ของฉันถึงล้มเหลวบน xlsx ที่ถูกต้อง

เพราะชื่อ part ที่คุณจำไว้เป็นแค่ธรรมเนียมของ producer รายหนึ่ง ไม่ใช่ข้อกำหนดของรูปแบบไฟล์ ทุก path ที่คุณเคย hardcode ไว้ ทั้ง xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml ล้วนเป็นสิ่งที่ตัวเขียนของ Excel เดสก์ท็อปบังเอิญปล่อยออกมา package ที่สอดคล้องตามสเปกอาจวาง workbook ไว้ที่ office/book.xml และเวิร์กชีตแรกไว้ที่ xl/custom/data-sheet.xml แล้วก็ยังเป็น SpreadsheetML ที่ถูกกฎหมาย ตราบใดที่ relationship ชี้ไปที่นั่น นี่คือเหตุผลที่พบบ่อยที่สุดที่ทำให้ตัวอ่านที่สร้างขึ้นเองรายงานว่า "หา sheet1.xml ไม่พบ" บนไฟล์ที่ Excel, LibreOffice และ Numbers ทั้งหมดเปิดได้โดยไม่มีปัญหาเลย

Producer ที่ทำแบบนี้ไม่ใช่เรื่องแปลกประหลาด ตัวสร้างรายงานฝั่งเซิร์ฟเวอร์นำ template package กลับมาใช้ใหม่และคง layout เดิมของมันไว้ pipeline การ export ที่รวม workbook สองไฟล์เข้าด้วยกันเปลี่ยนหมายเลข sheet ใหม่และทิ้งช่องว่างไว้ ดังนั้น workbook ที่มีห้า sheet จึงมี sheet1, sheet2, sheet4, sheet7 และ sheet9 เครื่องมือที่ลบ sheet ออกไม่เสมอไปที่จะเปลี่ยนหมายเลขตัวที่รอดใหม่ ในทุกกรณีเหล่านี้ การเดาแบบใช้ index xl/worksheets/sheet + IntToStr(i + 1) + .xml จะอ่าน sheet ผิดหรือไม่อ่านอะไรเลยอย่างเงียบ ๆ ซึ่งแย่กว่า exception เพราะ workbook โหลดได้แต่ตัวเลขผิด package ขั้นต่ำด้านล่างนี้ครอบคลุมปัญหาทั้งหมด และเป็นรูปแบบที่ HotXLS ใช้ regression-test

<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
      Target="office/book.xml"/>
</Relationships>

<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId42"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
      Target="../xl/custom/data-sheet.xml"/>
</Relationships>

<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="note7"
      Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
      Target="../notes/review.xml"/>
</Relationships>

ISO/IEC 29500-2 รับประกันอะไรจริง ๆ

มันรับประกันการเข้าถึงได้ ไม่ใช่ตำแหน่ง ISO/IEC 29500-2 เป็นส่วน Open Packaging Conventions ของมาตรฐานนี้ และข้อกำหนดเรื่อง relationship ของมันนิยามจุดเข้าที่ตายตัวไว้พอดีหนึ่งจุดเท่านั้น: relationship part ของ package ที่ _rels/.rels จากตรงนั้นคุณไล่ตาม relationship ที่ Type ของมันคือ http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument เพื่อไปถึง workbook part และ part อื่นทุกตัวถูกค้นพบด้วยการอ่าน relationship part ของ part นั้นเองแล้วไล่ตาม edge ที่มี type ออกไปเรื่อย ๆ

มีอีกสองกฎจากมาตรฐานเดียวกันนี้ที่ทำงานจริง ข้อกำหนดเรื่องการตั้งชื่อ part กำหนดว่า relationship part หนึ่งอยู่ที่ไหน: สำหรับ part ที่ <folder>/<name> relationship ของมันอยู่ที่ <folder>/_rels/<name>.rels และสำหรับ part ที่ root ของ package folder ก็แค่ _rels/ ข้อกำหนดเรื่อง markup ของ relationship ระบุว่า Target เป็น URI reference ที่ resolve เทียบกับ URI ของ part ต้นทาง ในความหมายปกติของ RFC 3986 เว้นแต่ TargetMode="External" จะกำกับว่ามันชี้ไปนอก package การ resolve แบบสัมพัทธ์กับต้นทางเป็นขั้นตอนที่ทุกคนข้ามไป และนี่คือเหตุผลที่ literal ../notes/review.xml เดียวกันหมายถึงสิ่งหนึ่งภายใน xl/custom/_rels/data-sheet.xml.rels และหมายถึงสิ่งที่ต่างไปโดยสิ้นเชิงภายในไฟล์ rels ที่ลึกลงไปอีกหนึ่ง folder ยังมีจุดย่อยสุดท้ายอยู่ระหว่างโมเดล logical กับ byte บน disk: ชื่อ part ในโมเดล logical เป็นแบบสัมบูรณ์และขึ้นต้นด้วย forward slash แต่ข้อกำหนดเรื่อง ZIP physical mapping ตัด slash นั้นออกเมื่อมันแปลงชื่อ part ให้เป็นชื่อ item ของ ZIP ดังนั้น resolver ที่ลืมเรื่องนี้จะค้นหา /xl/sharedStrings.xml ใน archive แล้วไม่พบอะไรเลย

ภายใน XlsxResolveRelationshipTarget

HotXLS รวมกฎการ resolve ทั้งหมดไว้ในฟังก์ชันเดียวคือ XlsxResolveRelationshipTarget ประกาศไว้ใน lxHandleX.pas เป็น function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString มันรับชื่อ item ของ ZIP ของ part ต้นทางกับ attribute Target ดิบ แล้วคืนชื่อ item ของ ZIP ที่ไม่มี slash นำหน้า พร้อมส่งตรงให้ archive ใช้งานได้เลย การส่ง OwnerPartName ที่ว่างเปล่าจะ resolve เทียบกับ root ของ package ซึ่งเป็นสิ่งที่ relationship part ของ package ต้องการพอดี ลำดับของการดำเนินการสำคัญกว่าแต่ละขั้นตอนแยกกัน: backslash ถูก normalize เป็น forward slash ก่อน เพราะ producer บางรายเขียน separator แบบ Windows ลงใน Target fragment ใดก็ตามที่นำเข้ามาด้วย # ถูกตัดก่อนการจัดการ path ดังนั้น ../charts/chart1.xml#Sheet1 จึง resolve ไปเป็นชื่อ part แทนที่จะเป็น archive entry ที่ไม่มีอยู่จริง หลังจากนั้นเท่านั้นที่ฟังก์ชันจะแยกแบบสัมบูรณ์ออกจากแบบสัมพัทธ์

// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
  combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
  Delete(combined, 1, 1)              // package-absolute: strip the slash only
else
begin
  p := LastDelimiter('/', String(OwnerPartName));
  if p > 0 then
    baseName := Copy(OwnerPartName, 1, p)
  else
    baseName := '';
  combined := baseName + combined;    // relative to the source part folder
end;

source.StrictDelimiter := True;       // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
  segment := WideString(source[i]);
  if (segment = '') or (segment = '.') then
    Continue;                         // empty and dot segments vanish
  if segment = '..' then
  begin
    if parts.Count > 0 then
      parts.Delete(parts.Count - 1);  // pop, and never below the root
  end
  else
    parts.Add(String(segment));
end;

ลูปของ segment เป็นแค่การไล่ stack ธรรมดา: segment ที่ว่างเปล่ากับ . ถูกทิ้งไป .. pop หนึ่งระดับ และ .. ที่จะหลุดออกนอก root ของ package จะถูกดูดซับไว้แทนที่จะสร้าง index ติดลบหรือชื่อที่ขึ้นต้นด้วย ../ การกำหนด StrictDelimiter := True ไม่ใช่แค่ของตกแต่ง หากไม่มีมัน TStringList ของ Delphi จะปฏิบัติต่อช่องว่างเป็น delimiter และเคารพอักขระ quote ซึ่งจะทำให้ชื่อ part ใดก็ตามที่มีช่องว่างเสียหาย และชื่อ part ที่มีช่องว่างนั้นถูกกฎหมาย

ไล่ตาม graph: workbook, worksheet, drawing

HotXLS ไล่ผ่าน relationship part สามชั้นบน path ของ TXLSXWorkbook.Open ชั้น package จัดการโดย XlsxFindOfficeDocumentPart ซึ่งอ่าน _rels/.rels และคืนเป้าหมายของ officeDocument ชั้น workbook อ่าน relationship part ของ workbook แล้วสร้าง map สองตัวพร้อมกัน: identifier map สำหรับการค้นหา r:id และ type map สำหรับ part ที่มีตัวเดียว ชั้น worksheet กับ drawing ทำซ้ำรูปแบบนี้ด้วย ParseWorksheetRelsXml และ ParseDrawingRelsXml โดยแต่ละตัวส่งชื่อ part ของตัวเองเป็นฐานการ resolve ดังนั้น drawing ที่อ้างถึง ../media/image3.png จึงลงเอยที่ blob ที่ถูกต้อง

// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
  WorkbookPartName := 'xl/workbook.xml';        // legacy fallback
if not zip.Exists(WorkbookPartName) then
  Exit;

// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParsePartRelationshipsXml(relsStream, WorkbookPartName,
      WorkbookTargetById, WorkbookTargetsByType);
  finally
    relsStream.Free;
  end;
end;

// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
  PartName := 'xl/sharedStrings.xml';

Sheet โดยเฉพาะต้องผ่าน identifier map ไม่ใช่ type map element <sheet> ใน workbook part บรรทุก attribute r:id ไว้ และ identifier นั้นเป็นสิ่งเดียวที่ผูกชื่อ sheet เข้ากับ part HotXLS รวบรวม identifier เหล่านั้นระหว่าง ParseWorkbookXml และ resolve แต่ละตัวเทียบกับ workbook relationship map โดยถอยกลับไปใช้ชื่อแบบมีหมายเลขตามธรรมเนียมก็ต่อเมื่อ identifier ไม่มีอยู่หรือ resolve ไม่ได้เท่านั้น

// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
  PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
  PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));

// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
  relsStream := zip.OpenFile(relsName);
  try
    ParseWorksheetRelsXml(relsStream, PartName,
      FParRels[i], ParTableTargets[i], ParPartTargets[i]);
  finally
    relsStream.Free;
  end;
end;

ทุกอย่างที่อยู่ปลายทางขี่กลไกเดียวกันนี้อยู่ Shared string, style, theme, VBA project ภายใต้ type ที่ใช้ namespace ของ Microsoft คือ http://schemas.microsoft.com/office/2006/relationships/vbaProject, external link, part person ที่ผูกกับ workbook, comment แบบเก่า, threaded comment, VML drawing ที่บรรทุกรูปทรงบอลลูนของ comment, drawing, ภาพ, chart, table และ PivotTable ทั้งหมดเข้าถึง byte ของมันผ่านเป้าหมายที่ resolve แล้ว theme part โดยเฉพาะต้องถูกค้นหาได้ถูกต้อง มิฉะนั้นการ round-trip จะเขียนทับ brand palette ของลูกค้าด้วย theme มาตรฐานของ Office อย่างเงียบ ๆ ซึ่งเป็นหนึ่งในโหมดความล้มเหลวที่ครอบคลุมไว้ในบันทึกเรื่อง การ round-trip ของ XLSX แบบไม่สูญเสียข้อมูลสำหรับ theme, extLst และ calcChain การอ่าน relationship ยังเป็นเหตุผลที่การโหลดถูกแบ่งเป็นขั้นตอนแบบที่เป็นอยู่ด้วย: การเข้าถึง archive ทั้งหมดเกิดขึ้นบน thread เดียวก่อนที่ XML ของ worksheet จะถูก parse เพราะ state การ inflate ของ ZIP archive ไม่ปลอดภัยต่อ thread ซึ่งเป็นข้อจำกัดที่อธิบายไว้ในบทความเรื่อง การ parse XLSX แบบขนานและ memory allocator

ทำไม rId ที่ซ้ำกันถึงทำให้การกำหนดเส้นทางตาม type พัง

เพราะ entry ที่ผิดรูปแบบตัวหลังสามารถเขียนทับตัวก่อนหน้าที่ถูกต้องแล้วยึดการค้นหาไปได้ Relationship identifier ควรจะไม่ซ้ำกันภายใน relationship part หนึ่ง แต่ package ที่ผิดรูปแบบใช้มันซ้ำ และการกำหนดค่าแบบไร้เดียงสาอย่าง Values[Id] := คือแบบเขียนล่าสุดชนะ ถ้า rId3 ตัวแรกชี้ไปที่ worksheet จริง และ rId3 ตัวที่สองชี้ไปที่เป้าหมายที่ไม่รองรับหรือว่างเปล่า แบบเขียนล่าสุดชนะจะทำให้เสีย worksheet ไป ParsePartRelationshipsXml จึงใช้กฎแบบตัวแรกชนะที่มีสองเงื่อนไข: เป้าหมายที่ resolve แล้วต้องไม่ว่างเปล่า และ identifier ต้องไม่มีอยู่แล้ว ทั้งสองเงื่อนไขรวมกันคือสิ่งที่ทำให้มันปลอดภัย เพราะการทดสอบว่าไม่ว่างเปล่าหยุด relationship ที่ไม่มี Target จากการยึดตำแหน่งไว้ก่อนที่ตัวที่ใช้งานได้จะมาถึง

if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
  (TargetById.IndexOfName(String(Id)) < 0) then
  TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
  TargetsByType.Add(String(relType + '=' + resolvedTarget));

สังเกตความไม่สมมาตรที่จงใจไว้ใน snippet นั้น identifier map เป็น map จริงที่มีการป้องกันแบบตัวแรกชนะ ในขณะที่การรวบรวม type เป็นแค่รายการแบบต่อท้ายอย่างเดียวของคู่ type=target ความแตกต่างนั้นสำคัญ: workbook มี relationship ของ shared-strings แค่หนึ่งเดียวแต่มี relationship ของ worksheet และ external-link หลายตัว ดังนั้นการค้นหาตาม type ผ่าน Values[] จึงคืนค่าที่ตรงกันตัวแรกสำหรับ part แบบตัวเดียว และ type ที่มีหลายค่าอย่าง externalLink จะถูกไล่ผ่านด้วยการวนรายการแทน

การไล่ตาม relationship หยุดอยู่ตรงไหน

ขอบเขตที่ซื่อตรงสำคัญกว่าเรื่องราวที่สะอาดสวยงาม HotXLS ถอยกลับไปใช้ชื่อตามธรรมเนียมทุกครั้งที่ relationship ไม่มีอยู่ ดังนั้น package ที่ relationship part เสียหายหรือหายไปก็ยังเปิดได้ถ้ามันบังเอิญเป็นไปตาม layout ของ Excel fallback นั้นเป็นฟีเจอร์ด้านความเข้ากันได้ ไม่ใช่แหล่งความจริงที่สอง และมันสามารถบดบังบั๊กของ producer ระหว่างการทดสอบได้ มีอีกสามขีดจำกัดที่ควรรู้ไว้ เป้าหมายที่ทำเครื่องหมาย TargetMode="External" ถูกเก็บไว้ตามตัวอักษรแทนที่จะถูก resolve ซึ่งถูกต้องสำหรับ hyperlink และสำหรับ relationship externalLinkPath ที่บรรทุก URL ของ workbook ระยะไกล แต่มันหมายความว่าค่าที่คุณได้กลับมาคือสิ่งที่ producer เขียนไว้ทุกประการ chart part ที่ค้นพบผ่าน drawing relationship part จะถูกจับคู่กับ drawing anchor ตามตำแหน่งแทนที่จะเป็นตาม identifier ดังนั้นลำดับ anchor ที่ผิดปกติอาจทำให้การผูก chart เพี้ยนไป และตัวอ่านแบบ streaming โดยตรงใน lxDirectRead.pas ก็คงการจัดการ path ที่เบากว่าของตัวเองที่ key กับ xl/ ไว้ ดังนั้น resolver แบบเต็มที่อธิบายไว้ที่นี่จึงควบคุมจุดเข้าของ TXLSXWorkbook.Open และ GetSheetNames ไม่ใช่ path การสแกนแบบใช้หน่วยความจำต่ำที่ระบุไว้ในบทความเรื่อง ตัวอ่านแบบ streaming โดยตรงสำหรับ Delphi

ถ้าคุณกำลังจะสร้างสิ่งนี้ขึ้นมาเอง สรุปที่ถูกต้องที่สั้นที่สุดคือ: อย่าสร้างชื่อ part ขึ้นมาเอง ให้ resolve มันเสมอ อ่าน _rels/.rels ไล่ตาม officeDocument resolve ทุก Target เทียบกับ part ที่ประกาศมัน และกำหนดเส้นทาง sheet ด้วย r:id ถ้าคุณอยากได้สิ่งที่ถูกทดสอบแล้วกับ part ที่เปลี่ยนชื่อ การนับหมายเลข sheet ที่ไม่ต่อเนื่อง และ relationship identifier ที่ซ้ำกัน resolver ที่อธิบายไว้ที่นี่มาพร้อมกับ HotXLS Delphi spreadsheet component ควบคู่ไปกับกลไก round-trip ที่คง part ที่มันไม่ได้ parse ไว้อย่างสมบูรณ์