Bài viết kỹ thuật

Giải quyết quan hệ OPC của XLSX trong bộ phân tích Delphi

Một tệp xlsx hợp lệ không nhất thiết phải chứa xl/worksheets/sheet1.xml. HotXLS, thành phần bảng tính Excel gốc cho Delphi và C++Builder, định vị mọi part thông qua đồ thị quan hệ OPC thay vì đoán tên, vì ISO/IEC 29500-2 chỉ bảo đảm rằng các part có thể tới được từ _rels/.rels, không bao giờ bảo đảm chúng nằm ở các đường dẫn theo quy ước

Vì sao bộ phân tích của tôi thất bại trên một tệp xlsx hợp lệ?

Vì những đường dẫn part bạn đã ghi nhớ chỉ là quy ước của một bên tạo ra, không phải là yêu cầu của định dạng. Mọi đường dẫn bạn từng gắn cứng, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, là những gì trình ghi Excel desktop tình cờ phát ra. Một gói tuân thủ chuẩn có thể đặt workbook tại office/book.xml và worksheet đầu tiên tại xl/custom/data-sheet.xml và vẫn là SpreadsheetML hợp lệ, miễn là các quan hệ trỏ tới đó. Đây chính là lý do phổ biến nhất khiến một bộ đọc tự chế báo "không tìm thấy sheet1.xml" trên một tệp mà Excel, LibreOffice, và Numbers đều mở không một lời phàn nàn

Những bên tạo ra làm điều này không phải là hiếm gặp. Các bộ tạo báo cáo phía máy chủ tái sử dụng một gói mẫu và giữ nguyên bố cục gốc của nó. Các pipeline xuất gộp hai workbook đánh số lại sheet và để lại các khoảng trống, nên một workbook năm sheet có sheet1, sheet2, sheet4, sheet7, và sheet9. Các công cụ loại bỏ một sheet không phải lúc nào cũng đánh số lại những sheet còn sống sót. Trong mọi trường hợp đó, việc đoán dựa trên chỉ số xl/worksheets/sheet + IntToStr(i + 1) + .xml âm thầm đọc sai sheet hoặc không đọc được gì cả, điều này còn tệ hơn một ngoại lệ vì workbook vẫn tải được và các con số thì sai. Gói tối giản dưới đây vận hành toàn bộ vấn đề này, và đây chính là hình dạng mà HotXLS chạy test hồi quy dựa vào

<!-- _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 thực sự bảo đảm điều gì?

Nó bảo đảm khả năng tới được (reachability), không phải vị trí. ISO/IEC 29500-2 là phần Open Packaging Conventions của chuẩn này, và điều khoản về quan hệ của nó định nghĩa đúng một điểm vào cố định duy nhất: part quan hệ của gói tại _rels/.rels. Từ đó bạn theo quan hệ có Typehttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument để tới được part workbook, và mọi part khác được khám phá bằng cách đọc part quan hệ của chính part đó rồi theo các cạnh có kiểu ra bên ngoài

Hai quy tắc nữa từ cùng chuẩn đó làm nên công việc thật sự. Điều khoản đặt tên part cố định nơi một part quan hệ sống: với một part tại <folder>/<name>, các quan hệ của nó nằm tại <folder>/_rels/<name>.rels, và với một part ở gốc gói thì thư mục đơn giản là _rels/. Điều khoản đánh dấu quan hệ nói rằng Target là một tham chiếu URI được giải quyết dựa trên URI của part nguồn, theo nghĩa RFC 3986 thông thường, trừ khi TargetMode="External" đánh dấu nó trỏ ra bên ngoài gói. Việc giải quyết tương đối theo nguồn là bước mà ai cũng bỏ qua, và đó là lý do cùng một chuỗi literal ../notes/review.xml mang một nghĩa bên trong xl/custom/_rels/data-sheet.xml.rels và một nghĩa hoàn toàn khác bên trong một tệp rels nằm sâu hơn một thư mục. Còn một điểm gấp khúc cuối cùng nằm giữa mô hình logic và các byte trên đĩa: tên part trong mô hình logic là tuyệt đối và bắt đầu bằng một dấu gạch chéo, nhưng điều khoản ánh xạ vật lý ZIP loại bỏ dấu gạch chéo đó khi nó biến một tên part thành một tên mục ZIP, nên một bộ giải quyết quên điều này sẽ tra cứu /xl/sharedStrings.xml trong archive và không tìm thấy gì cả

Bên trong XlsxResolveRelationshipTarget

HotXLS tập trung toàn bộ quy tắc giải quyết vào một hàm duy nhất, XlsxResolveRelationshipTarget, khai báo trong lxHandleX.pas dưới dạng function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Nó nhận tên mục ZIP của part nguồn và thuộc tính Target thô, và trả về một tên mục ZIP không có dấu gạch chéo dẫn đầu, sẵn sàng để đưa thẳng cho archive. Truyền vào một OwnerPartName rỗng sẽ giải quyết dựa trên gốc gói, đúng là những gì part quan hệ của gói cần. Thứ tự các bước quan trọng hơn từng bước riêng lẻ: dấu gạch chéo ngược được chuẩn hóa thành dấu gạch chéo xuôi trước tiên, vì một số bên tạo ghi các dấu phân cách kiểu Windows vào Target; bất kỳ fragment nào được giới thiệu bởi # đều bị cắt bỏ trước khi xử lý đường dẫn, nên ../charts/chart1.xml#Sheet1 giải quyết ra một tên part thay vì ra một mục archive không tồn tại; chỉ sau đó hàm này mới tách tuyệt đối khỏi tương đối

// 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;

Vòng lặp qua từng đoạn (segment) là một lượt duyệt stack thuần túy: các đoạn rỗng và . bị loại bỏ, .. pop đi một cấp, và một .. lẽ ra sẽ thoát ra khỏi gốc gói thì bị hấp thụ thay vì tạo ra một chỉ số âm hay một tên bắt đầu bằng ../. Phép gán StrictDelimiter := True không phải để trang trí. Không có nó, một TStringList của Delphi coi khoảng trắng là dấu phân cách và tôn trọng các ký tự trích dẫn, điều này làm hỏng bất kỳ tên part nào chứa khoảng trắng, và tên part có khoảng trắng là hợp lệ

Theo dấu đồ thị: workbook, worksheet, drawing

HotXLS duyệt qua ba tầng part quan hệ trên đường TXLSXWorkbook.Open. Tầng gói được xử lý bởi XlsxFindOfficeDocumentPart, hàm này đọc _rels/.rels và trả về đích officeDocument. Tầng workbook đọc part quan hệ của workbook và xây hai bản đồ cùng lúc: một bản đồ định danh cho các lượt tra cứu r:id và một bản đồ kiểu cho các part đơn nhất (singleton). Tầng worksheet và drawing lặp lại mẫu hình này với ParseWorksheetRelsXmlParseDrawingRelsXml, mỗi hàm truyền vào tên part của chính nó làm cơ sở giải quyết để một drawing tham chiếu ../media/image3.png rơi vào đúng 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 cụ thể phải đi qua bản đồ định danh, không phải bản đồ kiểu. Các phần tử <sheet> trong part workbook mang thuộc tính r:id, và định danh đó là thứ duy nhất ràng buộc một tên sheet với một part. HotXLS thu thập những định danh đó trong ParseWorkbookXml và giải quyết từng cái dựa trên bản đồ quan hệ workbook, chỉ rơi về tên đánh số theo quy ước khi định danh vắng mặt hoặc không thể giải quyết

// 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;

Mọi thứ phía sau đều dựa trên chính cơ chế đó. Shared strings, styles, theme, dự án VBA dưới kiểu có namespace Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, liên kết ngoài, part person ở phạm vi workbook, comment kiểu cũ, threaded comment, drawing VML mang hình học bong bóng comment, drawing, hình ảnh, biểu đồ, bảng, và PivotTable — tất cả đều tới được các byte của chúng thông qua các đích đã giải quyết. Part theme đặc biệt phải được định vị đúng, nếu không một vòng đọc-ghi (round-trip) sẽ âm thầm ghi đè bảng màu thương hiệu của khách hàng bằng theme Office chuẩn, một trong những chế độ thất bại được trình bày trong ghi chú về round-trip XLSX không mất dữ liệu của theme, extLst, và calcChain. Việc đọc quan hệ cũng chính là lý do việc tải được phân giai đoạn theo cách này: mọi truy cập archive diễn ra trên một luồng duy nhất trước khi XML worksheet được phân tích, vì trạng thái giải nén (inflate) của một archive ZIP không an toàn với đa luồng, một ràng buộc được giải thích trong bài viết về phân tích XLSX song song và bộ cấp phát bộ nhớ

Vì sao một rId trùng lặp làm hỏng việc định tuyến theo kiểu?

Vì một mục bị hỏng xuất hiện sau có thể ghi đè lên một mục hợp lệ xuất hiện trước và chiếm đoạt lượt tra cứu. Định danh quan hệ đáng lẽ phải là duy nhất trong một part quan hệ, nhưng các gói bị hỏng vẫn tái sử dụng chúng, và một phép gán ngây thơ Values[Id] := sẽ theo kiểu ghi-sau-thắng. Nếu rId3 đầu tiên trỏ tới một worksheet thật và một rId3 thứ hai trỏ tới một đích không được hỗ trợ hoặc rỗng, ghi-sau-thắng sẽ làm mất worksheet đó. Vì vậy ParsePartRelationshipsXml áp dụng một quy tắc ghi-trước-thắng với hai điều kiện: đích đã giải quyết phải không rỗng, và định danh không được đã có mặt sẵn. Cả hai điều kiện cùng nhau mới là thứ khiến nó an toàn, vì phép kiểm tra không-rỗng ngăn một quan hệ thiếu Target chiếm chỗ trước khi một quan hệ dùng được tới

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));

Hãy chú ý sự bất đối xứng có chủ đích trong đoạn mã đó. Bản đồ định danh là một bản đồ thật với một lớp bảo vệ ghi-trước-thắng, trong khi tập hợp kiểu là một danh sách chỉ-nối-thêm gồm các cặp type=target. Sự khác biệt đó mang tính then chốt: một workbook có đúng một quan hệ shared-strings nhưng nhiều quan hệ worksheet và liên kết ngoài, nên tra cứu kiểu qua Values[] trả về lần khớp đầu tiên cho các part đơn nhất, và các kiểu đa giá trị như externalLink được liệt kê bằng cách duyệt qua danh sách

Việc theo dấu quan hệ dừng lại ở đâu

Ranh giới trung thực quan trọng hơn một câu chuyện gọn gàng. HotXLS rơi về các tên theo quy ước bất cứ khi nào một quan hệ vắng mặt, nên một gói có một part quan hệ bị hỏng hay thiếu vẫn mở được nếu nó tình cờ theo bố cục Excel; sự rơi về đó là một tính năng tương thích, không phải một nguồn sự thật thứ hai, và nó có thể che giấu một lỗi của bên tạo trong quá trình test. Còn ba giới hạn nữa đáng biết. Các đích được đánh dấu TargetMode="External" được lưu nguyên văn thay vì được giải quyết, điều này đúng đối với hyperlink và đối với quan hệ externalLinkPath mang một URL workbook từ xa, nhưng nó nghĩa là giá trị bạn nhận lại đúng là bất cứ thứ gì bên tạo đã ghi. Các part biểu đồ được khám phá qua một part quan hệ drawing được ghép với các anchor drawing theo vị trí thay vì theo định danh, nên một thứ tự anchor bất thường có thể làm lệch các ràng buộc biểu đồ. Và bộ đọc trực tiếp dạng streaming trong lxDirectRead.pas giữ đường xử lý part nhẹ hơn của riêng nó gắn với xl/, nên bộ giải quyết đầy đủ được mô tả ở đây chi phối các điểm vào TXLSXWorkbook.OpenGetSheetNames, không phải đường quét ít cấp phát được tài liệu hóa trong bài viết về bộ đọc trực tiếp dạng streaming cho Delphi

Nếu bạn tự xây dựng điều này, tóm tắt đúng ngắn gọn nhất là: không bao giờ tự dựng một tên part, luôn luôn giải quyết một tên part. Đọc _rels/.rels, theo officeDocument, giải quyết mọi Target dựa trên part đã khai báo nó, và định tuyến sheet theo r:id. Nếu bạn muốn có sẵn thứ đã được test đầy đủ với các part đã đổi tên, đánh số sheet không liên tục, và các định danh quan hệ trùng lặp, bộ giải quyết được mô tả ở đây được cung cấp trong thành phần bảng tính Delphi của HotXLS, cùng với cơ chế round-trip giữ nguyên vẹn các part mà nó không phân tích